Compare commits

..

13 Commits

23 changed files with 1343 additions and 198 deletions

View File

@@ -6,3 +6,8 @@ ADMIN_ACCOUNT_ID=replace-with-private-admin-id
ADMIN_ACCOUNT_PASSWORD=replace-with-private-admin-password ADMIN_ACCOUNT_PASSWORD=replace-with-private-admin-password
ADMIN_ACCOUNT_EMAIL=admin@example.com ADMIN_ACCOUNT_EMAIL=admin@example.com
ADMIN_ACCOUNT_NICKNAME=Planner Admin ADMIN_ACCOUNT_NICKNAME=Planner Admin
APP_BASE_URL=https://planner.sori.studio
RESEND_API_KEY=replace-with-private-resend-api-key
MAIL_FROM_EMAIL=planner@sori.studio
MAIL_FROM_NAME=10 Minute Planner
AUTH_PREVIEW_LINKS=false

View File

@@ -4,7 +4,7 @@
- 프로젝트명: 10 Minute Planner 웹 UI - 프로젝트명: 10 Minute Planner 웹 UI
- 기술 스택: Vue 3 + Vite + TailwindCSS + JavaScript - 기술 스택: Vue 3 + Vite + TailwindCSS + JavaScript
- 현재 기준 버전: `v0.1.42` 준비 중 - 현재 기준 버전: `v0.1.47` 준비 중
- Git 원격 저장소: `https://git.sori.studio/zenn/planner.sori.studio.git` - Git 원격 저장소: `https://git.sori.studio/zenn/planner.sori.studio.git`
## 기준 디자인 ## 기준 디자인
@@ -53,7 +53,8 @@
- 달력 상단은 월 좌우 화살표, 클릭형 연도 선택, `TODAY` 버튼 구조로 동작한다. - 달력 상단은 월 좌우 화살표, 클릭형 연도 선택, `TODAY` 버튼 구조로 동작한다.
- 입력 내용이 있는 날짜는 달력 하단에 빨간 점으로 표시된다. - 입력 내용이 있는 날짜는 달력 하단에 빨간 점으로 표시된다.
- 상단 날짜 표시에서는 토요일의 `(토)`만 파란색, 일요일의 `(일)`만 빨간색으로 표시한다. - 상단 날짜 표시에서는 토요일의 `(토)`만 파란색, 일요일의 `(일)`만 빨간색으로 표시한다.
- 플래너 상태는 `localStorage`에 저장되며, 날짜별 기록과 선택 날짜, 달력 보고 있던 월까지 복원다. - 플래너 상태는 `localStorage`에 저장되며, 날짜별 기록과 통계 범위 같은 UI 상태를 복원다.
- 플래너 기록 자체는 로컬에 남기되, 앱을 새로 열 때 기본 기준 날짜는 마지막 열람 날짜가 아니라 항상 오늘 날짜로 맞춘다.
- `localStorage` 접근 로직은 `src/lib/plannerStorage.js`로 분리하기 시작했고, 이후 API/DB adapter로 교체하기 쉬운 구조로 정리 중이다. - `localStorage` 접근 로직은 `src/lib/plannerStorage.js`로 분리하기 시작했고, 이후 API/DB adapter로 교체하기 쉬운 구조로 정리 중이다.
- 프론트는 헤더에서 `LOGIN` / `SIGN UP` 모달을 열 수 있고, 로그인 상태면 닉네임과 `LOGOUT` 버튼을 표시한다. - 프론트는 헤더에서 `LOGIN` / `SIGN UP` 모달을 열 수 있고, 로그인 상태면 닉네임과 `LOGOUT` 버튼을 표시한다.
- 인증 토큰과 현재 사용자 정보는 프론트 로컬 저장소에 따로 유지하고, 앱 시작 시 `/api/auth/me`로 세션 복원을 시도한다. - 인증 토큰과 현재 사용자 정보는 프론트 로컬 저장소에 따로 유지하고, 앱 시작 시 `/api/auth/me`로 세션 복원을 시도한다.
@@ -195,7 +196,7 @@
- 모바일 대응 이후 인쇄에서 `TIME TABLE`이 사라지던 문제를 막기 위해, print 시에는 `PlannerPage` 내부 레이아웃을 다시 가로 배치로 고정하고 타임테이블 오버플로를 해제하도록 보정했다. - 모바일 대응 이후 인쇄에서 `TIME TABLE`이 사라지던 문제를 막기 위해, print 시에는 `PlannerPage` 내부 레이아웃을 다시 가로 배치로 고정하고 타임테이블 오버플로를 해제하도록 보정했다.
- 인쇄 레이아웃은 추가로 미세 조정해 `COMMENT` 영역이 잘리지 않도록 textarea 높이/행간을 print 전용으로 풀고, `1-UP` / `2-UP` 배율도 프레임 실측 기준으로 다시 계산했다. - 인쇄 레이아웃은 추가로 미세 조정해 `COMMENT` 영역이 잘리지 않도록 textarea 높이/행간을 print 전용으로 풀고, `1-UP` / `2-UP` 배율도 프레임 실측 기준으로 다시 계산했다.
- `TODO.md`는 중복 체크 항목을 정리했고, 인증 확장을 위해 `이메일 인증 / 비밀번호 재설정 / rate limit / 메일 인프라` 작업을 별도 항목으로 추가했다. - `TODO.md`는 중복 체크 항목을 정리했고, 인증 확장을 위해 `이메일 인증 / 비밀번호 재설정 / rate limit / 메일 인프라` 작업을 별도 항목으로 추가했다.
- Resend 무료 플랜은 도메인 1개 제약이 있어 현재 프로젝트 인증 메일에는 바로 쓰기 어렵다. 다음 단계에서는 AWS SES 또는 범용 SMTP 공급자 기준으로 메일 발송 추상화를 붙이는 쪽이 적합하다. - 메일 발송은 현재 Resend 기준으로 운영한다. 도메인 정책이나 플랜 조건이 실제 운영과 어긋나는 시점이 오면 그때 대체 수단을 재검토한다.
- 현재 인증 메일/재설정 메일은 실제 발송 대신 개발용 `previewUrl`을 응답으로 돌려주는 단계다. 프론트 UI 연결과 실제 메일러 연결은 다음 단계에서 마무리하면 된다. - 현재 인증 메일/재설정 메일은 실제 발송 대신 개발용 `previewUrl`을 응답으로 돌려주는 단계다. 프론트 UI 연결과 실제 메일러 연결은 다음 단계에서 마무리하면 된다.
- 미니 달력 날짜 버튼은 원형 비율이 흔들리지 않도록 고정 `width/height` 기준으로 다시 맞췄다. - 미니 달력 날짜 버튼은 원형 비율이 흔들리지 않도록 고정 `width/height` 기준으로 다시 맞췄다.
- 플래너 본문 D-DAY 텍스트는 3줄까지만 보이고, 넘치면 말줄임 처리되도록 정리했다. - 플래너 본문 D-DAY 텍스트는 3줄까지만 보이고, 넘치면 말줄임 처리되도록 정리했다.
@@ -219,15 +220,32 @@
- 플래너 본문 시간 라벨은 `총 시간`에서 `FOCUSED TIME`으로 바꿨다. 인쇄 CSS에서 COMMENT/FOCUSED TIME 라벨이 잘리지 않도록 부모 overflow를 열고, COMMENT는 남는 폭을 채우며 FOCUSED TIME은 오른쪽 210px 칸에 붙도록 조정했다. - 플래너 본문 시간 라벨은 `총 시간`에서 `FOCUSED TIME`으로 바꿨다. 인쇄 CSS에서 COMMENT/FOCUSED TIME 라벨이 잘리지 않도록 부모 overflow를 열고, COMMENT는 남는 폭을 채우며 FOCUSED TIME은 오른쪽 210px 칸에 붙도록 조정했다.
- Docker 배포용 루트 `.env`와 개발용 `.env.dev`를 분리했다. `docker-compose.yml`은 운영 `.env`, `docker-compose.dev.yml`은 기존 dev Postgres 볼륨과 맞는 `.env.dev`를 읽는다. - Docker 배포용 루트 `.env`와 개발용 `.env.dev`를 분리했다. `docker-compose.yml`은 운영 `.env`, `docker-compose.dev.yml`은 기존 dev Postgres 볼륨과 맞는 `.env.dev`를 읽는다.
- STATS의 `RANGE FLOW`는 항목 수에 따라 막대 폭과 간격을 조정한다. 1주일 내외는 넓은 막대로 카드 폭을 채우고, 1달 내외는 좁은 막대와 요약 라벨로 한 화면에서 흐름을 보며, 세부 날짜와 집중 시간은 막대 hover 팝업으로 확인한다. - STATS의 `RANGE FLOW`는 항목 수에 따라 막대 폭과 간격을 조정한다. 1주일 내외는 넓은 막대로 카드 폭을 채우고, 1달 내외는 좁은 막대와 요약 라벨로 한 화면에서 흐름을 보며, 세부 날짜와 집중 시간은 막대 hover 팝업으로 확인한다.
- 왼쪽 사이드바의 인쇄 영역은 `PRINT` 버튼 하나로 줄이고, 클릭 시 모달에서 시작일/종료일과 `1페이지씩` 또는 `2페이지씩` 출력 방식을 선택한다. 인쇄 전용 렌더링은 선택 기간의 날짜를 순서대로 여러 장 생성하며, 2페이지씩 출력에서 홀수 날짜가 남으면 오른쪽은 빈 페이지 프레임으로 둔다.
- 회원가입과 프로필 수정 시 이메일뿐 아니라 닉네임 중복도 서버에서 409로 막는다. 비밀번호 재설정 API는 로그인 모달의 `비밀번호 찾기` 흐름과 연결되어 있고, `/reset-password?token=...` URL로 들어오면 새 비밀번호 설정 모드가 열린다. 실제 메일 발송 전까지는 백엔드 응답의 `resetPreviewUrl`을 개발용 링크로 표시한다.
- Resend 메일러가 추가되었다. `RESEND_API_KEY`, `MAIL_FROM_EMAIL`, `MAIL_FROM_NAME`, `APP_BASE_URL` 환경변수를 설정하면 이메일 인증과 비밀번호 재설정 메일을 실제로 발송한다. API 키는 저장소에 커밋하지 말고 루트 `.env`/`.env.dev`에만 넣는다.
- 메일 발송 인프라는 현재 Resend 기준으로 확정했다. 자동 로그아웃 옵션은 플래너를 오래 띄워두는 사용 흐름과 맞지 않아 TODO 대상에서 제외했다.
- 인증/비밀번호 재설정 개발용 링크는 `AUTH_PREVIEW_LINKS=true`일 때만 API 응답에 포함된다. 운영 `.env`는 반드시 `AUTH_PREVIEW_LINKS=false`로 두어야 하며, 현재 예시 파일도 false가 기본값이다.
- 회원가입은 이제 자동 로그인되지 않는다. 인증 메일 발송 후 `이메일 인증 후 로그인` 안내만 보여주고, 일반 사용자는 이메일 인증 전까지 로그인할 수 없다.
- 기존에 발급된 세션이라도 일반 사용자 이메일 인증이 안 되어 있으면 `/api/auth/me` 단계에서 세션을 즉시 폐기한다. 운영 중 인증 정책을 켠 뒤에도 미인증 세션이 남지 않게 하기 위한 장치다.
- 비밀번호 재설정/이메일 인증용 개발 링크는 `AUTH_PREVIEW_LINKS=true`여도 `APP_BASE_URL``localhost` 또는 `127.0.0.1`일 때만 응답에 포함한다. 상용 서버에서 링크가 그대로 보이면 이 조건부터 확인한다.
- 프론트는 `/verify-email?token=...` 진입 시 인증을 바로 확정하고 로그인 모달에 결과 메시지를 띄운다. `/reset-password?token=...`은 기존처럼 비밀번호 재설정 모달을 연다.
- SETTINGS 화면에 일반 사용자 전용 `회원 탈퇴` 카드가 추가되었다. 현재 비밀번호 확인 후 계정, 플래너 기록, 목표, 세션, 인증 토큰이 함께 삭제된다. 기본 관리자 계정은 이 경로에서 삭제하지 못하게 막는다.
- `/api/auth/logout`이 추가되어 로그아웃 시 프론트 저장 토큰만 지우는 것이 아니라 서버 세션도 함께 폐기한다.
- SETTINGS 화면 왼쪽 카드에 현재 기기 로그인 유지 방식(`로그인 유지` 또는 브라우저 세션만 유지), 최근 로그인 시각, 이메일 인증 상태를 보여준다.
- 현재 로그인 유지 방식은 `authPersist` 상태로 함께 들고 가며, 프로필 저장 후에도 원래 저장 방식(localStorage/sessionStorage)을 유지하도록 정리했다.
- 사용자 테이블에 `disabledAt` 컬럼이 추가되었다. 관리자가 비활성화한 계정은 즉시 현재 세션이 모두 종료되고, 이후 로그인도 차단된다.
- 관리자 화면에서 일반 사용자 계정에 대해 `비활성화/다시 허용`, `강제 로그아웃`, `삭제`를 실행할 수 있다. 관리자 계정은 이 화면에서 비활성화하거나 삭제하지 못하게 막는다.
- 관리자 사용자 목록에는 현재 활성 세션 수와 비활성화 상태가 함께 표시된다.
- `5173` 포트가 직접 `npm run dev` 없이 열려 있으면 대부분 `ten-minute-frontend-dev` 컨테이너가 떠 있는 상태다. 개발용 Docker를 끄려면 `docker compose -f docker-compose.dev.yml down`을 사용한다. - `5173` 포트가 직접 `npm run dev` 없이 열려 있으면 대부분 `ten-minute-frontend-dev` 컨테이너가 떠 있는 상태다. 개발용 Docker를 끄려면 `docker compose -f docker-compose.dev.yml down`을 사용한다.
- 현재 환경에서는 Docker 데몬이 꺼져 있어서 `docker compose build` 실검증은 하지 못했고, 데몬 시작 후 다시 확인이 필요하다. - 현재 환경에서는 Docker 데몬이 꺼져 있어서 `docker compose build` 실검증은 하지 못했고, 데몬 시작 후 다시 확인이 필요하다.
- 이미지 저장 기능은 추후 `print-only` 또는 별도 export 전용 레이아웃을 기준으로 구현하면 화면/인쇄/공유 결과를 맞추기 쉽다. - 이미지 저장 기능은 추후 `print-only` 또는 별도 export 전용 레이아웃을 기준으로 구현하면 화면/인쇄/공유 결과를 맞추기 쉽다.
- Docker Compose는 프론트엔드와 백엔드를 함께 올리는 기준으로 설계하되, NAS 환경에 맞는 볼륨과 재시작 정책도 함께 고려한다. - Docker Compose는 프론트엔드와 백엔드를 함께 올리는 기준으로 설계하되, NAS 환경에 맞는 볼륨과 재시작 정책도 함께 고려한다.
- Docker Compose 개발/배포 파일에는 `TZ=Asia/Seoul`을 명시해 NAS와 컨테이너 내부 기준 시간이 한국 시간으로 맞도록 유지한다.
- 플래너 본문 라벨은 더 이상 `bg-paper` 배경으로 선을 덮지 않는다. `라벨 + 오른쪽 선` 구조로 바꿔 화면과 인쇄에서 노란 배경이 튀지 않도록 정리했다. - 플래너 본문 라벨은 더 이상 `bg-paper` 배경으로 선을 덮지 않는다. `라벨 + 오른쪽 선` 구조로 바꿔 화면과 인쇄에서 노란 배경이 튀지 않도록 정리했다.
- 날짜에 적용되는 목표가 새로 생기면 D-DAY는 기본 표시된다. 사용자가 해당 날짜에서 직접 `D-DAY 사용`을 끈 경우에만 로컬 숨김 목록에 저장해 다시 숨긴다. - 날짜에 적용되는 목표가 새로 생기면 D-DAY는 기본 표시된다. 사용자가 해당 날짜에서 직접 `D-DAY 사용`을 끈 경우에만 로컬 숨김 목록에 저장해 다시 숨긴다.
- 비로그인 랜딩은 모바일에서 `카드 안 카드`처럼 보이지 않도록 기능 설명 카드를 얇은 리스트로 단순화했고, `LOGIN` / `SIGN UP` 버튼은 같은 너비와 높이로 맞췄다. 로그인/회원가입 모달도 하단 전환 영역을 별도 카드 대신 구분선 형태로 정리했다. - 비로그인 랜딩은 모바일에서 `카드 안 카드`처럼 보이지 않도록 기능 설명 카드를 얇은 리스트로 단순화했고, `LOGIN` / `SIGN UP` 버튼은 같은 너비와 높이로 맞췄다. 로그인/회원가입 모달도 하단 전환 영역을 별도 카드 대신 구분선 형태로 정리했다.
- 로그인 모달에 `로그인 유지` 체크박스를 추가했다. 기본값은 OFF이며, OFF 상태에서는 인증 토큰을 `sessionStorage`에 저장해 브라우저 세션이 끝나면 사라지고, ON 상태에서만 `localStorage`에 저장한다. - 로그인 모달에 `로그인 유지` 체크박스를 추가했다. 기본값은 OFF이며, OFF 상태에서는 인증 토큰을 `sessionStorage`에 저장해 브라우저 세션이 끝나면 사라지고, ON 상태에서만 `localStorage`에 저장한다.
- 현재 로그아웃은 프론트 저장 토큰을 지우는 수준이다. 개인 기록 서비스 성격을 고려하면 다음 단계에서 서버 세션 폐기 API와 미사용 자동 로그아웃 옵션을 추가하는 편이 좋다. - 현재 로그아웃은 `/api/auth/logout`으로 서버 세션까지 함께 폐기한다. 자동 로그아웃 옵션은 플래너를 오래 열어두는 사용 흐름과 맞지 않아 추가 대상에서 제외했다.
- 비로그인 랜딩에 `DEMO VIEW`를 추가했다. 데모는 실제 저장/로그인 상태와 분리된 읽기 전용 샘플이며, 어제/오늘/내일 3일치 플래너를 전환해서 제품 감각을 먼저 볼 수 있다. - 비로그인 랜딩에 `DEMO VIEW`를 추가했다. 데모는 실제 저장/로그인 상태와 분리된 읽기 전용 샘플이며, 어제/오늘/내일 3일치 플래너를 전환해서 제품 감각을 먼저 볼 수 있다.
- 플래너 본문 `MEMO``TIME TABLE` 하단 높이를 맞추기 위해 TASK/MEMO 리스트 간격과 행 높이를 조정했다. TASK 드래그 선택 피드백은 레이아웃 흔들림을 줄이도록 ring 대신 배경색만 사용한다. - 플래너 본문 `MEMO``TIME TABLE` 하단 높이를 맞추기 위해 TASK/MEMO 리스트 간격과 행 높이를 조정했다. TASK 드래그 선택 피드백은 레이아웃 흔들림을 줄이도록 ring 대신 배경색만 사용한다.
- 이월된 할 일은 `carryoverFrom` 날짜를 가진다. TASK 본문에는 `이월` 배지를 표시하고, 클릭하면 오른쪽 `READ NEXT` 영역에 원래 시작 날짜를 안내한다. - 이월된 할 일은 `carryoverFrom` 날짜를 가진다. TASK 본문에는 `이월` 배지를 표시하고, 클릭하면 오른쪽 `READ NEXT` 영역에 원래 시작 날짜를 안내한다.

View File

@@ -61,13 +61,15 @@ docker compose up -d --build
관리자 계정은 백엔드 시작 시 `.env``ADMIN_ACCOUNT_*` 값으로 자동 생성된다. 관리자 계정은 백엔드 시작 시 `.env``ADMIN_ACCOUNT_*` 값으로 자동 생성된다.
관리자 아이디와 비밀번호는 저장소 문서에 적지 말고, 운영자만 접근 가능한 비공개 환경변수 파일에서 관리한다. 관리자 아이디와 비밀번호는 저장소 문서에 적지 말고, 운영자만 접근 가능한 비공개 환경변수 파일에서 관리한다.
일반 사용자는 기존처럼 회원가입 후 이메일로 로그인하면 된다. 일반 사용자는 회원가입 후 이메일 인증을 완료해야 로그인할 수 있다.
운영 서버에서는 비밀번호 재설정/이메일 인증용 미리보기 링크가 API 응답에 노출되지 않도록 유지한다.
현재 `docker-compose.yml` 기준 내부 구성: 현재 `docker-compose.yml` 기준 내부 구성:
- 프론트엔드 nginx - 프론트엔드 nginx
- 백엔드 Fastify - 백엔드 Fastify
- PostgreSQL - PostgreSQL
- 배포/개발 컨테이너 시간대: `Asia/Seoul`
### 3. 실행 상태 확인하기 ### 3. 실행 상태 확인하기

31
TODO.md
View File

@@ -51,14 +51,14 @@
## 4단계: 데이터 구조와 저장 ## 4단계: 데이터 구조와 저장
- [ ] 플래너 데이터 구조를 날짜별 상태 중심으로 정리한다. - [x] 플래너 데이터 구조를 날짜별 상태 중심으로 정리한다.
- [x] 입력 데이터의 저장 위치를 결정한다. - [x] 입력 데이터의 저장 위치를 결정한다.
- [x] 로컬 저장 또는 외부 저장 방식 중 우선 구현 방식을 정한다. - [x] 로컬 저장 또는 외부 저장 방식 중 우선 구현 방식을 정한다.
- [x] 입력 상태가 새로고침 후에도 유지되도록 만든다. - [x] 입력 상태가 새로고침 후에도 유지되도록 만든다.
- [x] DB 전환 시점을 잡을 수 있도록 저장 레이어를 분리한다. - [x] DB 전환 시점을 잡을 수 있도록 저장 레이어를 분리한다.
- [x] 회원 가입 및 로그인 구조를 고려한 사용자별 데이터 모델을 설계한다. - [x] 회원 가입 및 로그인 구조를 고려한 사용자별 데이터 모델을 설계한다.
- [x] 사용자별 문서 저장/조회 흐름을 정리한다. - [x] 사용자별 문서 저장/조회 흐름을 정리한다.
- [ ] 출력용 문서 포맷과 프린트 흐름을 고려한 데이터 구조를 정리한다. - [x] 출력용 문서 포맷과 프린트 흐름을 고려한 데이터 구조를 정리한다.
## 추가 반영 메모 ## 추가 반영 메모
@@ -71,7 +71,7 @@
- [x] 통계 페이지 라우팅 또는 화면 전환 구조를 설계한다. - [x] 통계 페이지 라우팅 또는 화면 전환 구조를 설계한다.
- [x] 집중 시간, 완료율, 연속 기록 같은 핵심 지표를 정의한다. - [x] 집중 시간, 완료율, 연속 기록 같은 핵심 지표를 정의한다.
- [x] 사용자가 시작일과 종료일을 선택해서 기간별 통계를 볼 수 있게 한다. - [x] 사용자가 시작일과 종료일을 선택해서 기간별 통계를 볼 수 있게 한다.
- [ ] 사용자 개인 통계 화면 기준을 정리한다. - [x] 사용자 개인 통계 화면 기준을 정리한다.
## 6단계: 계정 및 서비스 확장 ## 6단계: 계정 및 서비스 확장
@@ -79,8 +79,8 @@
- [x] 사용자 설정 화면에서 닉네임 / 이메일 / 비밀번호 수정 흐름을 분리한다. - [x] 사용자 설정 화면에서 닉네임 / 이메일 / 비밀번호 수정 흐름을 분리한다.
- [x] 상단 헤더를 왼쪽 사이드 내비게이션 구조로 재배치한다. - [x] 상단 헤더를 왼쪽 사이드 내비게이션 구조로 재배치한다.
- [x] 본문과 오른쪽 패널이 각각 독립 스크롤되도록 조정한다. - [x] 본문과 오른쪽 패널이 각각 독립 스크롤되도록 조정한다.
- [ ] 사용자별 문서 분리 저장 구조를 설계한다. - [x] 사용자별 문서 분리 저장 구조를 설계한다.
- [ ] 공유가 아닌 개인 보관용 서비스 흐름으로 요구사항을 정리한다. - [x] 공유가 아닌 개인 보관용 서비스 흐름으로 요구사항을 정리한다.
- [x] 향후 출력 기능을 위한 인쇄 레이아웃 요구사항을 정리한다. - [x] 향후 출력 기능을 위한 인쇄 레이아웃 요구사항을 정리한다.
- [x] A4 가로 기준 2장 출력 모드를 지원한다. - [x] A4 가로 기준 2장 출력 모드를 지원한다.
- [x] `1-UP` 세로 인쇄 / `2-UP` 가로 인쇄 기준을 분리한다. - [x] `1-UP` 세로 인쇄 / `2-UP` 가로 인쇄 기준을 분리한다.
@@ -94,27 +94,23 @@
- [x] 미완료 항목을 다음 날짜 빈칸으로 이월하는 버튼을 추가한다. - [x] 미완료 항목을 다음 날짜 빈칸으로 이월하는 버튼을 추가한다.
- [x] 이월된 할 일에 배지를 표시하고 원래 시작 날짜를 확인할 수 있게 한다. - [x] 이월된 할 일에 배지를 표시하고 원래 시작 날짜를 확인할 수 있게 한다.
- [x] 이월된 할 일을 체크할 때 이전 날짜까지 함께 완료할지 선택하는 정책을 추가한다. - [x] 이월된 할 일을 체크할 때 이전 날짜까지 함께 완료할지 선택하는 정책을 추가한다.
- [ ] 이메일 인증 플로우를 설계하고 구현한다. - [x] 이메일 인증 플로우를 설계하고 구현한다.
- [ ] 비밀번호 찾기 / 재설정 토큰 흐름을 추가한다. - [x] 비밀번호 찾기 / 재설정 토큰 흐름을 추가한다.
- [ ] 로그인 및 인증 관련 rate limit / 잠금 정책을 추가한다. - [ ] 로그인 및 인증 관련 rate limit / 잠금 정책을 추가한다.
- [x] 로그인 유지 여부를 사용자가 선택할 수 있게 한다. - [x] 로그인 유지 여부를 사용자가 선택할 수 있게 한다.
- [ ] 시간 미사용 시 자동 로그아웃 옵션을 추가한다. - [x] 화면에서 현재 기기 로그인 상태와 저장 방식을 안내한다.
- [ ] 설정 화면에서 현재 기기 로그인 상태와 저장 방식을 안내한다. - [x] 서버 세션을 명시적으로 폐기하는 로그아웃 API를 추가한다.
- [ ] 서버 세션을 명시적으로 폐기하는 로그아웃 API를 추가한다. - [x] 메일 발송 인프라와 발신 도메인 정책을 Resend 기준으로 확정한다.
- [ ] 메일 발송 인프라와 발신 도메인 정책을 확정한다. - [x] 관리자 페이지에서 계정 비활성화 / 강제 로그아웃 / 삭제 기능을 추가한다.
- [ ] Resend 무료 플랜 대체 수단으로 SES 또는 일반 SMTP 연동 방식을 확정한다.
- [ ] 관리자 페이지에서 계정 비활성화 / 강제 로그아웃 / 삭제 기능을 추가한다.
- [ ] 관리자 페이지에서 사용자별 문서 상세 조회 기능을 추가한다. - [ ] 관리자 페이지에서 사용자별 문서 상세 조회 기능을 추가한다.
- [ ] 관리자 페이지에서 검색 / 정렬 / 필터 UX를 추가한다. - [ ] 관리자 페이지에서 검색 / 정렬 / 필터 UX를 추가한다.
## 메모 ## 메모
- D-DAY는 현재 보류 상태다. 목표 패널 설계 후 연결한다.
- D-DAY는 본문에 직접 입력하는 방식보다, 별도 목표 목록에서 선택한 대표 목표를 보여주는 구조가 더 적합하다. - D-DAY는 본문에 직접 입력하는 방식보다, 별도 목표 목록에서 선택한 대표 목표를 보여주는 구조가 더 적합하다.
- 목표가 없는 경우 본문 D-DAY 영역은 숨기고, 오른쪽 패널의 `D-DAY 사용` 메뉴에서 검색/선택하도록 유도한다. - 목표가 없는 경우 본문 D-DAY 영역은 숨기고, 오른쪽 패널의 `D-DAY 사용` 메뉴에서 검색/선택하도록 유도한다.
- `TIME TABLE` 드래그는 단순 사각형 선택이 아니라 시간 셀 단위의 연속 선택으로 해석한다. - `TIME TABLE` 드래그는 단순 사각형 선택이 아니라 시간 셀 단위의 연속 선택으로 해석한다.
- 현재는 `localStorage`로 개발을 진행하지만, 적절한 시점에 DB를 붙여 사용자별 저장 구조로 확장해야 한다. - 로컬 저장은 비로그인/데모 보조 용도로만 남기고, 로그인 상태에서는 사용자별 서버 저장을 우선한다.
- 현재 `localStorage` 저장 로직은 분리 가능한 형태로 정리 중이며, 이후 API/DB adapter로 교체하기 쉽게 유지한다.
- 최종적으로는 회원 가입 후 각자 자신의 문서를 작성/관리하고, 개인 통계를 확인하며, 특정 날짜 문서를 출력할 수 있어야 한다. - 최종적으로는 회원 가입 후 각자 자신의 문서를 작성/관리하고, 개인 통계를 확인하며, 특정 날짜 문서를 출력할 수 있어야 한다.
- 실제 인쇄는 HTML/CSS 기반 프린트 레이아웃으로 유지하고, 공유용으로는 별도의 이미지 저장 기능을 추가하는 방향이 적합하다. - 실제 인쇄는 HTML/CSS 기반 프린트 레이아웃으로 유지하고, 공유용으로는 별도의 이미지 저장 기능을 추가하는 방향이 적합하다.
- 최종 배포는 UGREEN NAS에서 Docker 기반으로 동작할 예정이며, 포트와 실제 서비스 구성은 추후 확정한다. - 최종 배포는 UGREEN NAS에서 Docker 기반으로 동작할 예정이며, 포트와 실제 서비스 구성은 추후 확정한다.
@@ -134,8 +130,9 @@
- 현재 날짜에 적용된 목표가 있으면 D-DAY는 기본적으로 보이고, 사용자가 해당 날짜에서만 OFF로 끌 수 있다. - 현재 날짜에 적용된 목표가 있으면 D-DAY는 기본적으로 보이고, 사용자가 해당 날짜에서만 OFF로 끌 수 있다.
- 목표 생성 시 표시 시작일 기본값은 오늘, 표시 종료일 기본값은 목표일로 맞춘다. - 목표 생성 시 표시 시작일 기본값은 오늘, 표시 종료일 기본값은 목표일로 맞춘다.
- 표시 기간이 다른 진행 중 목표와 겹치면 프론트와 백엔드 모두 저장을 막는다. - 표시 기간이 다른 진행 중 목표와 겹치면 프론트와 백엔드 모두 저장을 막는다.
- 앱을 새로 열면 마지막 열람 날짜가 아니라 항상 오늘 날짜부터 시작하고, Docker/NAS 컨테이너 시간대는 `Asia/Seoul` 기준으로 맞춘다.
- TASK LABELS는 버튼 묶음 대신 동일한 토글 패턴으로 단순화했다. - TASK LABELS는 버튼 묶음 대신 동일한 토글 패턴으로 단순화했다.
- 구현할 때마다 완료된 항목은 체크하고, 큰 결정사항은 `HANDOFF.md`에도 함께 반영한다. - 구현할 때마다 완료된 항목은 체크하고, 큰 결정사항은 `HANDOFF.md`에도 함께 반영한다.
- Resend 무료 플랜도메인 제약이 있으므로, 실제 인증 메일은 AWS SES 또는 별도 SMTP 서비스 전환을 전제로 설계하는 편이 안전하다. - 메일 발송은 현재 Resend 기준으로 운영한다. 무료 플랜/도메인 제약이 바뀌는 시점에만 별도 대체 수단을 다시 검토한다.
- 관리자 기능은 읽기 전용 대시보드부터 시작하고, 실제 정리 액션은 권한/감사 로그 정책을 정한 뒤 추가하는 편이 안전하다. - 관리자 기능은 읽기 전용 대시보드부터 시작하고, 실제 정리 액션은 권한/감사 로그 정책을 정한 뒤 추가하는 편이 안전하다.
- 관리자 아이디/비밀번호는 README나 HANDOFF에 실제 값으로 남기지 않고, Docker 배포용 비공개 `.env`에서만 관리한다. - 관리자 아이디/비밀번호는 README나 HANDOFF에 실제 값으로 남기지 않고, Docker 배포용 비공개 `.env`에서만 관리한다.

View File

@@ -14,6 +14,10 @@ const envSchema = z.object({
CORS_ORIGIN: z.string().default('http://localhost:5173'), CORS_ORIGIN: z.string().default('http://localhost:5173'),
SESSION_TTL_DAYS: z.coerce.number().default(30), SESSION_TTL_DAYS: z.coerce.number().default(30),
APP_BASE_URL: z.string().default('http://localhost:5173'), APP_BASE_URL: z.string().default('http://localhost:5173'),
RESEND_API_KEY: z.string().optional(),
MAIL_FROM_EMAIL: z.string().email().default('planner@sori.studio'),
MAIL_FROM_NAME: z.string().default('10 Minute Planner'),
AUTH_PREVIEW_LINKS: z.coerce.boolean().default(false),
ADMIN_ACCOUNT_ID: z.string().min(1), ADMIN_ACCOUNT_ID: z.string().min(1),
ADMIN_ACCOUNT_PASSWORD: z.string().min(12), ADMIN_ACCOUNT_PASSWORD: z.string().min(12),
ADMIN_ACCOUNT_EMAIL: z.string().email(), ADMIN_ACCOUNT_EMAIL: z.string().email(),

View File

@@ -9,6 +9,7 @@ export async function ensureDatabaseSchema() {
password_hash VARCHAR(255) NOT NULL, password_hash VARCHAR(255) NOT NULL,
nickname VARCHAR(60) NOT NULL, nickname VARCHAR(60) NOT NULL,
role VARCHAR(20) NOT NULL DEFAULT 'user', role VARCHAR(20) NOT NULL DEFAULT 'user',
disabled_at TIMESTAMPTZ,
email_verified_at TIMESTAMPTZ, email_verified_at TIMESTAMPTZ,
last_login_at TIMESTAMPTZ, last_login_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL,
@@ -21,6 +22,9 @@ export async function ensureDatabaseSchema() {
ALTER TABLE users ALTER TABLE users
ADD COLUMN IF NOT EXISTS role VARCHAR(20) NOT NULL DEFAULT 'user'; ADD COLUMN IF NOT EXISTS role VARCHAR(20) NOT NULL DEFAULT 'user';
ALTER TABLE users
ADD COLUMN IF NOT EXISTS disabled_at TIMESTAMPTZ;
ALTER TABLE users ALTER TABLE users
ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ; ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ;

View File

@@ -16,6 +16,7 @@ export const users = pgTable('users', {
passwordHash: varchar('password_hash', { length: 255 }).notNull(), passwordHash: varchar('password_hash', { length: 255 }).notNull(),
nickname: varchar('nickname', { length: 60 }).notNull(), nickname: varchar('nickname', { length: 60 }).notNull(),
role: varchar('role', { length: 20 }).notNull().default('user'), role: varchar('role', { length: 20 }).notNull().default('user'),
disabledAt: timestamp('disabled_at', { withTimezone: true }),
emailVerifiedAt: timestamp('email_verified_at', { withTimezone: true }), emailVerifiedAt: timestamp('email_verified_at', { withTimezone: true }),
lastLoginAt: timestamp('last_login_at', { withTimezone: true }), lastLoginAt: timestamp('last_login_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull(), createdAt: timestamp('created_at', { withTimezone: true }).notNull(),

View File

@@ -14,6 +14,10 @@ function getBearerToken(request) {
return authorization.slice('Bearer '.length).trim() return authorization.slice('Bearer '.length).trim()
} }
export function getSessionTokenFromRequest(request) {
return getBearerToken(request)
}
export async function createSession(userId) { export async function createSession(userId) {
const token = createSessionToken() const token = createSessionToken()
const tokenHash = hashSessionToken(token) const tokenHash = hashSessionToken(token)
@@ -61,5 +65,29 @@ export async function findAuthenticatedUser(request) {
.where(eq(users.id, session.userId)) .where(eq(users.id, session.userId))
.limit(1) .limit(1)
if (user?.disabledAt) {
await db.delete(authSessions).where(eq(authSessions.id, session.id))
return null
}
if (user && user.role !== 'admin' && !user.emailVerifiedAt) {
await db.delete(authSessions).where(eq(authSessions.id, session.id))
return null
}
return user ?? null return user ?? null
} }
export async function revokeSessionByToken(token) {
if (!token) {
return false
}
const tokenHash = hashSessionToken(token)
const deletedSessions = await db
.delete(authSessions)
.where(eq(authSessions.tokenHash, tokenHash))
.returning({ id: authSessions.id })
return deletedSessions.length > 0
}

89
backend/src/lib/mailer.js Normal file
View File

@@ -0,0 +1,89 @@
import { env } from '../config.js'
const RESEND_API_URL = 'https://api.resend.com/emails'
function getFromAddress() {
return `${env.MAIL_FROM_NAME} <${env.MAIL_FROM_EMAIL}>`
}
async function sendWithResend({ to, subject, html, text }) {
if (!env.RESEND_API_KEY) {
return {
skipped: true,
reason: 'RESEND_API_KEY is not configured.',
}
}
const response = await fetch(RESEND_API_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${env.RESEND_API_KEY}`,
'Content-Type': 'application/json',
'User-Agent': 'ten-minute-planner/1.0',
},
body: JSON.stringify({
from: getFromAddress(),
to,
subject,
html,
text,
}),
})
const data = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(data.message || data.error?.message || 'Resend 메일 발송에 실패했습니다.')
}
return {
skipped: false,
id: data.id,
}
}
function buildLinkHtml({ title, description, linkUrl, buttonLabel }) {
return `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; color: #1c1917; line-height: 1.6;">
<p style="font-size: 12px; letter-spacing: 0.18em; text-transform: uppercase; color: #78716c;">10 Minute Planner</p>
<h1 style="font-size: 24px; margin: 12px 0;">${title}</h1>
<p style="font-size: 15px; color: #57534e;">${description}</p>
<p style="margin: 24px 0;">
<a href="${linkUrl}" style="display: inline-block; border-radius: 999px; background: #1c1917; color: #ffffff; padding: 12px 18px; font-size: 13px; font-weight: 700; text-decoration: none;">
${buttonLabel}
</a>
</p>
<p style="font-size: 12px; color: #78716c;">버튼이 열리지 않으면 아래 링크를 복사해서 브라우저에 붙여넣어 주세요.</p>
<p style="font-size: 12px; word-break: break-all; color: #57534e;">${linkUrl}</p>
<p style="font-size: 12px; color: #a8a29e;">이 링크는 30분 동안 사용할 수 있습니다.</p>
</div>
`
}
export function sendVerificationEmail({ to, linkUrl }) {
return sendWithResend({
to,
subject: '[10 Minute Planner] 이메일 인증',
html: buildLinkHtml({
title: '이메일 인증을 완료해 주세요.',
description: '아래 버튼을 눌러 10 Minute Planner 계정의 이메일 인증을 완료할 수 있습니다.',
linkUrl,
buttonLabel: '이메일 인증하기',
}),
text: `10 Minute Planner 이메일 인증 링크입니다.\n${linkUrl}\n이 링크는 30분 동안 사용할 수 있습니다.`,
})
}
export function sendPasswordResetEmail({ to, linkUrl }) {
return sendWithResend({
to,
subject: '[10 Minute Planner] 비밀번호 재설정',
html: buildLinkHtml({
title: '비밀번호를 재설정해 주세요.',
description: '아래 버튼을 눌러 새 비밀번호를 설정할 수 있습니다. 요청하지 않았다면 이 메일은 무시해 주세요.',
linkUrl,
buttonLabel: '비밀번호 재설정',
}),
text: `10 Minute Planner 비밀번호 재설정 링크입니다.\n${linkUrl}\n요청하지 않았다면 이 메일은 무시해 주세요.`,
})
}

View File

@@ -1,7 +1,13 @@
import { sql } from 'drizzle-orm' import { and, eq, sql } from 'drizzle-orm'
import { z } from 'zod'
import { db } from '../db/client.js' import { db } from '../db/client.js'
import { findAuthenticatedUser } from '../lib/authSession.js' import { findAuthenticatedUser } from '../lib/authSession.js'
import { users } from '../db/schema.js' import { authSessions, users } from '../db/schema.js'
const adminUserIdSchema = z.coerce.number().int().positive()
const adminStatusSchema = z.object({
disabled: z.boolean(),
})
async function requireAdminUser(request, reply) { async function requireAdminUser(request, reply) {
const user = await findAuthenticatedUser(request) const user = await findAuthenticatedUser(request)
@@ -35,6 +41,7 @@ export async function registerAdminRoutes(app) {
.select({ .select({
totalUsers: sql`count(*)::int`, totalUsers: sql`count(*)::int`,
totalAdmins: sql`count(*) filter (where ${users.role} = 'admin')::int`, totalAdmins: sql`count(*) filter (where ${users.role} = 'admin')::int`,
disabledUsers: sql`count(*) filter (where ${users.disabledAt} is not null)::int`,
verifiedUsers: sql`count(*) filter (where ${users.emailVerifiedAt} is not null)::int`, verifiedUsers: sql`count(*) filter (where ${users.emailVerifiedAt} is not null)::int`,
activeUsers30d: sql`count(*) filter (where ${users.lastLoginAt} >= now() - interval '30 days')::int`, activeUsers30d: sql`count(*) filter (where ${users.lastLoginAt} >= now() - interval '30 days')::int`,
newUsers7d: sql`count(*) filter (where ${users.createdAt} >= now() - interval '7 days')::int`, newUsers7d: sql`count(*) filter (where ${users.createdAt} >= now() - interval '7 days')::int`,
@@ -54,16 +61,19 @@ export async function registerAdminRoutes(app) {
u.nickname, u.nickname,
u.email, u.email,
u.role, u.role,
u.disabled_at as "disabledAt",
u.created_at as "createdAt", u.created_at as "createdAt",
u.email_verified_at as "emailVerifiedAt", u.email_verified_at as "emailVerifiedAt",
u.last_login_at as "lastLoginAt", u.last_login_at as "lastLoginAt",
count(distinct pe.id)::int as "plannerEntryCount", count(distinct pe.id)::int as "plannerEntryCount",
count(distinct g.id)::int as "goalCount", count(distinct g.id)::int as "goalCount",
count(distinct s.id)::int as "activeSessionCount",
max(pe.entry_date) as "lastEntryDate", max(pe.entry_date) as "lastEntryDate",
max(pe.updated_at) as "lastEntryUpdatedAt" max(pe.updated_at) as "lastEntryUpdatedAt"
from users u from users u
left join planner_entries pe on pe.user_id = u.id left join planner_entries pe on pe.user_id = u.id
left join goals g on g.user_id = u.id left join goals g on g.user_id = u.id
left join auth_sessions s on s.user_id = u.id and s.expires_at > now()
group by u.id group by u.id
order by coalesce(u.last_login_at, u.created_at) desc, u.id desc order by coalesce(u.last_login_at, u.created_at) desc, u.id desc
`) `)
@@ -88,6 +98,7 @@ export async function registerAdminRoutes(app) {
verifiedUsers: summary?.verifiedUsers ?? 0, verifiedUsers: summary?.verifiedUsers ?? 0,
activeUsers30d: summary?.activeUsers30d ?? 0, activeUsers30d: summary?.activeUsers30d ?? 0,
newUsers7d: summary?.newUsers7d ?? 0, newUsers7d: summary?.newUsers7d ?? 0,
disabledUsers: summary?.disabledUsers ?? 0,
totalPlannerEntries: plannerCountResult.rows[0]?.count ?? 0, totalPlannerEntries: plannerCountResult.rows[0]?.count ?? 0,
totalGoals: goalCountResult.rows[0]?.count ?? 0, totalGoals: goalCountResult.rows[0]?.count ?? 0,
}, },
@@ -100,4 +111,150 @@ export async function registerAdminRoutes(app) {
recentLogins: recentLoginsResult.rows, recentLogins: recentLoginsResult.rows,
} }
}) })
app.put('/api/admin/users/:userId/status', async (request, reply) => {
const adminUser = await requireAdminUser(request, reply)
if (!adminUser) {
return
}
const userIdResult = adminUserIdSchema.safeParse(request.params.userId)
const payload = adminStatusSchema.safeParse(request.body)
if (!userIdResult.success || !payload.success) {
return reply.code(400).send({
message: '관리자 요청 값이 올바르지 않습니다.',
})
}
const userId = userIdResult.data
const [targetUser] = await db
.select()
.from(users)
.where(eq(users.id, userId))
.limit(1)
if (!targetUser) {
return reply.code(404).send({
message: '대상 사용자를 찾을 수 없습니다.',
})
}
if (targetUser.role === 'admin') {
return reply.code(403).send({
message: '관리자 계정 상태는 여기서 변경할 수 없습니다.',
})
}
const disabled = payload.data.disabled
const now = new Date()
const [updatedUser] = await db
.update(users)
.set({
disabledAt: disabled ? now : null,
updatedAt: now,
})
.where(eq(users.id, userId))
.returning()
if (disabled) {
await db.delete(authSessions).where(eq(authSessions.userId, userId))
}
return {
message: disabled ? '계정을 비활성화했습니다.' : '계정을 다시 사용할 수 있게 했습니다.',
user: updatedUser,
}
})
app.post('/api/admin/users/:userId/revoke-sessions', async (request, reply) => {
const adminUser = await requireAdminUser(request, reply)
if (!adminUser) {
return
}
const userIdResult = adminUserIdSchema.safeParse(request.params.userId)
if (!userIdResult.success) {
return reply.code(400).send({
message: '대상 사용자 값이 올바르지 않습니다.',
})
}
const userId = userIdResult.data
const [targetUser] = await db
.select()
.from(users)
.where(eq(users.id, userId))
.limit(1)
if (!targetUser) {
return reply.code(404).send({
message: '대상 사용자를 찾을 수 없습니다.',
})
}
if (targetUser.role === 'admin') {
return reply.code(403).send({
message: '관리자 계정 세션은 여기서 정리하지 않습니다.',
})
}
const deletedSessions = await db
.delete(authSessions)
.where(eq(authSessions.userId, userId))
.returning({ id: authSessions.id })
return {
message: deletedSessions.length > 0 ? '해당 사용자의 로그인 세션을 모두 종료했습니다.' : '종료할 로그인 세션이 없습니다.',
revokedCount: deletedSessions.length,
}
})
app.delete('/api/admin/users/:userId', async (request, reply) => {
const adminUser = await requireAdminUser(request, reply)
if (!adminUser) {
return
}
const userIdResult = adminUserIdSchema.safeParse(request.params.userId)
if (!userIdResult.success) {
return reply.code(400).send({
message: '대상 사용자 값이 올바르지 않습니다.',
})
}
const userId = userIdResult.data
const [targetUser] = await db
.select()
.from(users)
.where(eq(users.id, userId))
.limit(1)
if (!targetUser) {
return reply.code(404).send({
message: '대상 사용자를 찾을 수 없습니다.',
})
}
if (targetUser.role === 'admin') {
return reply.code(403).send({
message: '관리자 계정은 여기서 삭제할 수 없습니다.',
})
}
await db.delete(users).where(and(eq(users.id, userId), eq(users.role, 'user')))
return {
message: '사용자 계정과 관련 데이터를 삭제했습니다.',
}
})
} }

View File

@@ -3,7 +3,8 @@ import { z } from 'zod'
import { db } from '../db/client.js' import { db } from '../db/client.js'
import { authSessions, emailVerificationTokens, passwordResetTokens, users } from '../db/schema.js' import { authSessions, emailVerificationTokens, passwordResetTokens, users } from '../db/schema.js'
import { createSessionToken, hashSessionToken, hashPassword, verifyPassword } from '../lib/password.js' import { createSessionToken, hashSessionToken, hashPassword, verifyPassword } from '../lib/password.js'
import { createSession, findAuthenticatedUser } from '../lib/authSession.js' import { createSession, findAuthenticatedUser, getSessionTokenFromRequest, revokeSessionByToken } from '../lib/authSession.js'
import { sendPasswordResetEmail, sendVerificationEmail } from '../lib/mailer.js'
import { env } from '../config.js' import { env } from '../config.js'
const signupSchema = z.object({ const signupSchema = z.object({
@@ -27,6 +28,10 @@ const passwordSchema = z.object({
newPassword: z.string().min(8).max(72), newPassword: z.string().min(8).max(72),
}) })
const deleteAccountSchema = z.object({
currentPassword: z.string().min(1).max(72),
})
const verificationRequestSchema = z.object({ const verificationRequestSchema = z.object({
email: z.string().trim().email().optional(), email: z.string().trim().email().optional(),
}) })
@@ -101,6 +106,7 @@ function sanitizeUser(user) {
loginId: user.loginId, loginId: user.loginId,
nickname: user.nickname, nickname: user.nickname,
role: user.role, role: user.role,
disabledAt: user.disabledAt,
emailVerifiedAt: user.emailVerifiedAt, emailVerifiedAt: user.emailVerifiedAt,
lastLoginAt: user.lastLoginAt, lastLoginAt: user.lastLoginAt,
createdAt: user.createdAt, createdAt: user.createdAt,
@@ -108,6 +114,31 @@ function sanitizeUser(user) {
} }
} }
function withPreviewUrl(payload, key, previewUrl) {
const allowPreviewLinks =
env.AUTH_PREVIEW_LINKS &&
/localhost|127\.0\.0\.1/i.test(env.APP_BASE_URL)
if (!allowPreviewLinks) {
return payload
}
return {
...payload,
[key]: previewUrl,
}
}
async function findUserByNickname(nickname) {
const [user] = await db
.select()
.from(users)
.where(eq(users.nickname, nickname))
.limit(1)
return user ?? null
}
export async function registerAuthRoutes(app) { export async function registerAuthRoutes(app) {
app.post('/api/auth/signup', async (request, reply) => { app.post('/api/auth/signup', async (request, reply) => {
const payload = signupSchema.safeParse(request.body) const payload = signupSchema.safeParse(request.body)
@@ -134,6 +165,14 @@ export async function registerAuthRoutes(app) {
}) })
} }
const existingNicknameUser = await findUserByNickname(nickname)
if (existingNicknameUser) {
return reply.code(409).send({
message: '이미 사용 중인 닉네임입니다.',
})
}
const now = new Date() const now = new Date()
const passwordHash = await hashPassword(password) const passwordHash = await hashPassword(password)
const [user] = await db const [user] = await db
@@ -145,21 +184,22 @@ export async function registerAuthRoutes(app) {
nickname, nickname,
role: 'user', role: 'user',
emailVerifiedAt: null, emailVerifiedAt: null,
lastLoginAt: now, lastLoginAt: null,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}) })
.returning() .returning()
const { token } = await createSession(user.id)
const verification = await createEmailVerificationToken(user.id) const verification = await createEmailVerificationToken(user.id)
await sendVerificationEmail({
return reply.code(201).send({ to: user.email,
message: '회원가입이 완료되었습니다.', linkUrl: verification.previewUrl,
token,
user: sanitizeUser(user),
verificationPreviewUrl: verification.previewUrl,
}) })
return reply.code(201).send(withPreviewUrl({
message: '회원가입이 완료되었습니다. 이메일 인증 후 로그인해 주세요.',
requiresEmailVerification: true,
}, 'verificationPreviewUrl', verification.previewUrl))
}) })
app.post('/api/auth/login', async (request, reply) => { app.post('/api/auth/login', async (request, reply) => {
@@ -200,6 +240,18 @@ export async function registerAuthRoutes(app) {
}) })
} }
if (user.disabledAt) {
return reply.code(403).send({
message: '비활성화된 계정입니다. 관리자에게 문의해 주세요.',
})
}
if (user.role !== 'admin' && !user.emailVerifiedAt) {
return reply.code(403).send({
message: '이메일 인증을 완료한 뒤 로그인해 주세요.',
})
}
const now = new Date() const now = new Date()
const [updatedUser] = await db const [updatedUser] = await db
@@ -234,6 +286,22 @@ export async function registerAuthRoutes(app) {
} }
}) })
app.post('/api/auth/logout', async (request, reply) => {
const token = getSessionTokenFromRequest(request)
if (!token) {
return reply.code(401).send({
message: '인증이 필요합니다.',
})
}
await revokeSessionByToken(token)
return {
message: '로그아웃되었습니다.',
}
})
app.put('/api/auth/profile', async (request, reply) => { app.put('/api/auth/profile', async (request, reply) => {
const user = await findAuthenticatedUser(request) const user = await findAuthenticatedUser(request)
@@ -266,6 +334,14 @@ export async function registerAuthRoutes(app) {
}) })
} }
const existingNicknameUser = await findUserByNickname(payload.data.nickname)
if (existingNicknameUser && existingNicknameUser.id !== user.id) {
return reply.code(409).send({
message: '이미 사용 중인 닉네임입니다.',
})
}
const [updatedUser] = await db const [updatedUser] = await db
.update(users) .update(users)
.set({ .set({
@@ -323,6 +399,46 @@ export async function registerAuthRoutes(app) {
} }
}) })
app.delete('/api/auth/account', async (request, reply) => {
const user = await findAuthenticatedUser(request)
if (!user) {
return reply.code(401).send({
message: '인증이 필요합니다.',
})
}
if (user.role === 'admin') {
return reply.code(403).send({
message: '기본 관리자 계정은 여기서 삭제할 수 없습니다.',
})
}
const payload = deleteAccountSchema.safeParse(request.body)
if (!payload.success) {
return reply.code(400).send({
message: '회원 탈퇴 확인 비밀번호를 입력해 주세요.',
issues: payload.error.flatten(),
})
}
const passwordMatches = await verifyPassword(payload.data.currentPassword, user.passwordHash)
if (!passwordMatches) {
return reply.code(401).send({
message: '현재 비밀번호가 올바르지 않습니다.',
})
}
await db.delete(authSessions).where(eq(authSessions.userId, user.id))
await db.delete(users).where(eq(users.id, user.id))
return {
message: '회원 탈퇴가 완료되었습니다.',
}
})
app.post('/api/auth/verification/request', async (request, reply) => { app.post('/api/auth/verification/request', async (request, reply) => {
const authenticatedUser = await findAuthenticatedUser(request) const authenticatedUser = await findAuthenticatedUser(request)
const payload = verificationRequestSchema.safeParse(request.body ?? {}) const payload = verificationRequestSchema.safeParse(request.body ?? {})
@@ -361,11 +477,14 @@ export async function registerAuthRoutes(app) {
} }
const verification = await createEmailVerificationToken(user.id) const verification = await createEmailVerificationToken(user.id)
await sendVerificationEmail({
to: user.email,
linkUrl: verification.previewUrl,
})
return { return withPreviewUrl({
message: '이메일 인증 링크를 준비했습니다.', message: '이메일 인증 링크를 준비했습니다.',
verificationPreviewUrl: verification.previewUrl, }, 'verificationPreviewUrl', verification.previewUrl)
}
}) })
app.post('/api/auth/verification/confirm', async (request, reply) => { app.post('/api/auth/verification/confirm', async (request, reply) => {
@@ -445,11 +564,14 @@ export async function registerAuthRoutes(app) {
} }
const reset = await createPasswordResetToken(user.id) const reset = await createPasswordResetToken(user.id)
await sendPasswordResetEmail({
to: user.email,
linkUrl: reset.previewUrl,
})
return { return withPreviewUrl({
message: '비밀번호 재설정 링크를 준비했습니다.', message: '비밀번호 재설정 링크를 준비했습니다.',
resetPreviewUrl: reset.previewUrl, }, 'resetPreviewUrl', reset.previewUrl)
}
}) })
app.post('/api/auth/password-reset/confirm', async (request, reply) => { app.post('/api/auth/password-reset/confirm', async (request, reply) => {

View File

@@ -4,6 +4,8 @@ services:
container_name: ten-minute-postgres-dev container_name: ten-minute-postgres-dev
env_file: env_file:
- ./.env.dev - ./.env.dev
environment:
TZ: Asia/Seoul
volumes: volumes:
- postgres_dev_data:/var/lib/postgresql/data - postgres_dev_data:/var/lib/postgresql/data
ports: ports:
@@ -26,6 +28,7 @@ services:
PORT: 3001 PORT: 3001
CORS_ORIGIN: http://localhost:5173 CORS_ORIGIN: http://localhost:5173
SESSION_TTL_DAYS: 30 SESSION_TTL_DAYS: 30
TZ: Asia/Seoul
volumes: volumes:
- ./backend:/app - ./backend:/app
- backend_node_modules:/app/node_modules - backend_node_modules:/app/node_modules
@@ -44,6 +47,7 @@ services:
environment: environment:
VITE_API_BASE_URL: http://localhost:3001 VITE_API_BASE_URL: http://localhost:3001
CHOKIDAR_USEPOLLING: "true" CHOKIDAR_USEPOLLING: "true"
TZ: Asia/Seoul
volumes: volumes:
- .:/app - .:/app
- /app/backend - /app/backend

View File

@@ -4,6 +4,8 @@ services:
container_name: ten-minute-postgres container_name: ten-minute-postgres
env_file: env_file:
- ./.env - ./.env
environment:
TZ: Asia/Seoul
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
ports: ports:
@@ -25,6 +27,7 @@ services:
PORT: 3001 PORT: 3001
CORS_ORIGIN: http://localhost:48081 CORS_ORIGIN: http://localhost:48081
SESSION_TTL_DAYS: 30 SESSION_TTL_DAYS: 30
TZ: Asia/Seoul
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@@ -38,6 +41,8 @@ services:
args: args:
VITE_API_BASE_URL: /api VITE_API_BASE_URL: /api
container_name: ten-minute-frontend container_name: ten-minute-frontend
environment:
TZ: Asia/Seoul
depends_on: depends_on:
- backend - backend
ports: ports:

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "ten-minute-planner", "name": "ten-minute-planner",
"version": "0.1.42", "version": "0.1.47",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ten-minute-planner", "name": "ten-minute-planner",
"version": "0.1.42", "version": "0.1.47",
"dependencies": { "dependencies": {
"vue": "^3.5.13" "vue": "^3.5.13"
}, },

View File

@@ -1,7 +1,7 @@
{ {
"name": "ten-minute-planner", "name": "ten-minute-planner",
"private": true, "private": true,
"version": "0.1.42", "version": "0.1.47",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -8,13 +8,23 @@ import MiniCalendar from './components/MiniCalendar.vue'
import PlannerPage from './components/PlannerPage.vue' import PlannerPage from './components/PlannerPage.vue'
import SettingsDashboard from './components/SettingsDashboard.vue' import SettingsDashboard from './components/SettingsDashboard.vue'
import StatsDashboard from './components/StatsDashboard.vue' import StatsDashboard from './components/StatsDashboard.vue'
import { fetchAdminOverview } from './lib/adminApi' import {
deleteAdminUser,
fetchAdminOverview,
revokeAdminUserSessions,
updateAdminUserStatus,
} from './lib/adminApi'
import { import {
clearAuthState, clearAuthState,
confirmVerification,
deleteAccount,
fetchCurrentUser, fetchCurrentUser,
logout as logoutRequest,
confirmPasswordReset,
login, login,
persistAuthState, persistAuthState,
readAuthState, readAuthState,
requestPasswordReset,
signup, signup,
updatePassword, updatePassword,
updateProfile, updateProfile,
@@ -25,7 +35,6 @@ import { deletePlannerEntry, fetchPlannerEntries, savePlannerEntry } from './lib
import { import {
createInitialPlannerRecords, createInitialPlannerRecords,
persistPlannerState, persistPlannerState,
readPlannerStorageState,
restorePlannerUiState, restorePlannerUiState,
} from './lib/plannerStorage' } from './lib/plannerStorage'
@@ -36,6 +45,7 @@ const CARRYOVER_CHECK_POLICIES = ['ask', 'all', 'current']
const screenMode = ref('planner') const screenMode = ref('planner')
const viewMode = ref('focus') const viewMode = ref('focus')
const printLayout = ref('single') const printLayout = ref('single')
const printDialogOpen = ref(false)
const demoMode = ref(false) const demoMode = ref(false)
const demoDayOffset = ref(0) const demoDayOffset = ref(0)
const authDialogOpen = ref(false) const authDialogOpen = ref(false)
@@ -43,6 +53,7 @@ const authMode = ref('login')
const authBusy = ref(false) const authBusy = ref(false)
const authMessage = ref('') const authMessage = ref('')
const authToken = ref('') const authToken = ref('')
const authPersist = ref(false)
const currentUser = ref(null) const currentUser = ref(null)
const goals = ref([]) const goals = ref([])
const goalQuery = ref('') const goalQuery = ref('')
@@ -59,10 +70,15 @@ const leftPanelOpen = ref(false)
const rightPanelOpen = ref(false) const rightPanelOpen = ref(false)
const statsRangeStart = ref(toKey(new Date(new Date().setDate(new Date().getDate() - 6)))) const statsRangeStart = ref(toKey(new Date(new Date().setDate(new Date().getDate() - 6))))
const statsRangeEnd = ref(toKey(new Date())) const statsRangeEnd = ref(toKey(new Date()))
const printRangeStart = ref(toKey(new Date()))
const printRangeEnd = ref(toKey(new Date()))
const printRangeLayout = ref('single')
const authForm = reactive({ const authForm = reactive({
nickname: '', nickname: '',
email: '', email: '',
password: '', password: '',
resetToken: '',
newPassword: '',
rememberSession: false, rememberSession: false,
}) })
const goalForm = reactive({ const goalForm = reactive({
@@ -80,10 +96,15 @@ const passwordForm = reactive({
newPassword: '', newPassword: '',
confirmPassword: '', confirmPassword: '',
}) })
const accountDeleteForm = reactive({
currentPassword: '',
})
const profileBusy = ref(false) const profileBusy = ref(false)
const passwordBusy = ref(false) const passwordBusy = ref(false)
const profileMessage = ref('') const profileMessage = ref('')
const passwordMessage = ref('') const passwordMessage = ref('')
const accountDeleteBusy = ref(false)
const accountDeleteMessage = ref('')
const carryoverMessage = ref('') const carryoverMessage = ref('')
const carryoverCheckPolicy = ref(readCarryoverCheckPolicy()) const carryoverCheckPolicy = ref(readCarryoverCheckPolicy())
const carryoverCheckPrompt = ref(null) const carryoverCheckPrompt = ref(null)
@@ -91,10 +112,12 @@ const guideTooltipResetMessage = ref('')
const hiddenGuideTooltips = ref(readHiddenGuideTooltips()) const hiddenGuideTooltips = ref(readHiddenGuideTooltips())
const ddayDisabledDateKeys = ref(readDdayDisabledDateKeys()) const ddayDisabledDateKeys = ref(readDdayDisabledDateKeys())
const adminBusy = ref(false) const adminBusy = ref(false)
const adminActionUserId = ref(null)
const adminMessage = ref('') const adminMessage = ref('')
const adminOverview = ref({ const adminOverview = ref({
totalUsers: 0, totalUsers: 0,
totalAdmins: 0, totalAdmins: 0,
disabledUsers: 0,
verifiedUsers: 0, verifiedUsers: 0,
activeUsers30d: 0, activeUsers30d: 0,
newUsers7d: 0, newUsers7d: 0,
@@ -550,6 +573,56 @@ const calendarDays = computed(() => {
}) })
}) })
const normalizedPrintRange = computed(() => {
const startKey = printRangeStart.value || toKey(selectedDate.value)
const endKey = printRangeEnd.value || startKey
if (startKey <= endKey) {
return { startKey, endKey }
}
return { startKey: endKey, endKey: startKey }
})
const printDateKeys = computed(() => {
const dateKeys = []
const currentDate = startOfDay(toDateValue(normalizedPrintRange.value.startKey))
const endDate = startOfDay(toDateValue(normalizedPrintRange.value.endKey))
while (currentDate <= endDate) {
dateKeys.push(toKey(currentDate))
currentDate.setDate(currentDate.getDate() + 1)
}
return dateKeys
})
const printPages = computed(() =>
printDateKeys.value.map((dateKey) => createPrintPage(dateKey)),
)
const printPapers = computed(() => {
if (printLayout.value === 'single') {
return printPages.value.map((page) => [page])
}
const papers = []
for (let index = 0; index < printPages.value.length; index += 2) {
papers.push(printPages.value.slice(index, index + 2))
}
return papers
})
const printPageCountLabel = computed(() => {
const dayCount = printDateKeys.value.length
const paperCount = printRangeLayout.value === 'double' ? Math.ceil(dayCount / 2) : dayCount
const layoutLabel = printRangeLayout.value === 'double' ? '2페이지씩' : '1페이지씩'
return `${dayCount}일치 / ${paperCount}장 (${layoutLabel})`
})
const markedDateKeys = computed(() => const markedDateKeys = computed(() =>
Object.entries(plannerRecords) Object.entries(plannerRecords)
.filter(([, record]) => hasPlannerContent(record)) .filter(([, record]) => hasPlannerContent(record))
@@ -558,6 +631,18 @@ const markedDateKeys = computed(() =>
const isAuthenticated = computed(() => Boolean(authToken.value && currentUser.value)) const isAuthenticated = computed(() => Boolean(authToken.value && currentUser.value))
const isAdmin = computed(() => currentUser.value?.role === 'admin') const isAdmin = computed(() => currentUser.value?.role === 'admin')
const authSessionInfo = computed(() => ({
storageLabel: authPersist.value ? '이 기기에서 로그인 유지 중' : '브라우저를 닫으면 로그아웃',
storageDescription: authPersist.value
? '현재 기기에서는 localStorage에 로그인 상태를 저장합니다.'
: '현재 기기에서는 sessionStorage에만 로그인 상태를 유지합니다.',
lastLoginLabel: formatSessionDate(currentUser.value?.lastLoginAt),
verificationLabel: currentUser.value?.role === 'admin'
? '관리자 기본 계정'
: currentUser.value?.emailVerifiedAt
? '이메일 인증 완료'
: '이메일 인증 필요',
}))
const filteredGoals = computed(() => { const filteredGoals = computed(() => {
const query = goalQuery.value.trim().toLowerCase() const query = goalQuery.value.trim().toLowerCase()
return goals.value.filter((goal) => { return goals.value.filter((goal) => {
@@ -801,6 +886,12 @@ function closeCarryoverCheckPrompt() {
} }
function handleGlobalKeydown(event) { function handleGlobalKeydown(event) {
if (event.key === 'Escape' && printDialogOpen.value) {
event.preventDefault()
closePrintDialog()
return
}
if (event.key === 'Escape' && carryoverCheckPrompt.value) { if (event.key === 'Escape' && carryoverCheckPrompt.value) {
event.preventDefault() event.preventDefault()
closeCarryoverCheckPrompt() closeCarryoverCheckPrompt()
@@ -971,6 +1062,56 @@ function createDateLabel(dateKey) {
return `${display.main} ${display.weekday}` return `${display.main} ${display.weekday}`
} }
function findPlannerGoalForDate(dateKey) {
const activeGoals = goals.value
.filter((goal) => {
if (!goal.activeFrom || !goal.activeUntil) {
return false
}
return dateKey >= goal.activeFrom && dateKey <= goal.activeUntil
})
.sort((left, right) => {
const currentDate = startOfDay(toDateValue(dateKey))
const leftDistance = Math.abs(startOfDay(toDateValue(left.targetDate)).getTime() - currentDate.getTime())
const rightDistance = Math.abs(startOfDay(toDateValue(right.targetDate)).getTime() - currentDate.getTime())
return leftDistance - rightDistance
})
return activeGoals[0] ?? null
}
function createPlannerDdayForDate(dateKey) {
if (isDdayDisabledForDate(dateKey)) {
return ''
}
const goal = findPlannerGoalForDate(dateKey)
if (!goal) {
return ''
}
const targetDate = startOfDay(toDateValue(goal.targetDate))
const currentDate = startOfDay(toDateValue(dateKey))
const diffDays = Math.round((targetDate.getTime() - currentDate.getTime()) / (24 * 60 * 60 * 1000))
const badge =
diffDays === 0 ? 'D-DAY' : diffDays > 0 ? `D-${diffDays}` : `D+${Math.abs(diffDays)}`
return `${badge} ${goal.title}`
}
function createPrintPage(dateKey) {
const date = toDateValue(dateKey)
return {
key: dateKey,
display: getDateDisplay(date),
record: getPlannerRecord(date),
dday: createPlannerDdayForDate(dateKey),
}
}
const weeklyRecords = computed(() => { const weeklyRecords = computed(() => {
const entries = rangeEntries.value.map(([key, record]) => { const entries = rangeEntries.value.map(([key, record]) => {
const date = toDateValue(key) const date = toDateValue(key)
@@ -1211,6 +1352,19 @@ function closeRightPanel() {
rightPanelOpen.value = false rightPanelOpen.value = false
} }
function openPrintDialog() {
const selectedKey = toKey(selectedDate.value)
printRangeStart.value = selectedKey
printRangeEnd.value = selectedKey
printRangeLayout.value = viewMode.value === 'spread' ? 'double' : 'single'
printDialogOpen.value = true
closeLeftPanel()
}
function closePrintDialog() {
printDialogOpen.value = false
}
function applyStatsQuickRange(days) { function applyStatsQuickRange(days) {
const endDate = new Date() const endDate = new Date()
const startDate = new Date(endDate) const startDate = new Date(endDate)
@@ -1292,6 +1446,8 @@ function resetAuthForm() {
authForm.nickname = '' authForm.nickname = ''
authForm.email = '' authForm.email = ''
authForm.password = '' authForm.password = ''
authForm.resetToken = ''
authForm.newPassword = ''
authForm.rememberSession = false authForm.rememberSession = false
} }
@@ -1308,18 +1464,96 @@ function syncProfileForm() {
profileForm.email = currentUser.value?.email ?? '' profileForm.email = currentUser.value?.email ?? ''
} }
function formatSessionDate(value) {
if (!value) {
return '기록이 없습니다.'
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return '기록이 없습니다.'
}
return `${date.getFullYear()}. ${`${date.getMonth() + 1}`.padStart(2, '0')}. ${`${date.getDate()}`.padStart(2, '0')} ${`${date.getHours()}`.padStart(2, '0')}:${`${date.getMinutes()}`.padStart(2, '0')}`
}
function resetPasswordForm() { function resetPasswordForm() {
passwordForm.currentPassword = '' passwordForm.currentPassword = ''
passwordForm.newPassword = '' passwordForm.newPassword = ''
passwordForm.confirmPassword = '' passwordForm.confirmPassword = ''
} }
function resetAccountDeleteForm() {
accountDeleteForm.currentPassword = ''
}
function openAuthDialog(mode = 'login') { function openAuthDialog(mode = 'login') {
authMode.value = mode authMode.value = mode
authMessage.value = '' authMessage.value = ''
authDialogOpen.value = true authDialogOpen.value = true
} }
function openPasswordResetFromUrl() {
if (typeof window === 'undefined') {
return
}
const url = new URL(window.location.href)
if (!url.pathname.includes('reset-password')) {
return
}
const token = url.searchParams.get('token') ?? ''
if (!token) {
return
}
authForm.resetToken = token
authMode.value = 'reset-confirm'
authMessage.value = ''
authDialogOpen.value = true
}
async function openVerificationFromUrl() {
if (typeof window === 'undefined') {
return
}
const url = new URL(window.location.href)
if (!url.pathname.includes('verify-email')) {
return
}
const token = url.searchParams.get('token') ?? ''
if (!token) {
authMode.value = 'login'
authMessage.value = '이메일 인증 링크가 올바르지 않습니다.'
authDialogOpen.value = true
return
}
authBusy.value = true
authMode.value = 'login'
authDialogOpen.value = true
try {
const result = await confirmVerification({ token })
authMessage.value = result.message || '이메일 인증이 완료되었습니다. 이제 로그인할 수 있습니다.'
url.pathname = '/'
url.search = ''
window.history.replaceState({}, '', url.toString())
} catch (error) {
authMessage.value = toUserFacingApiError(error, '이메일 인증 링크를 처리하지 못했습니다.')
} finally {
authBusy.value = false
}
}
function closeAuthDialog() { function closeAuthDialog() {
authDialogOpen.value = false authDialogOpen.value = false
authMessage.value = '' authMessage.value = ''
@@ -1332,6 +1566,7 @@ function updateAuthField({ field, value }) {
async function applyAuthSuccess(data, persist = false) { async function applyAuthSuccess(data, persist = false) {
authToken.value = data.token authToken.value = data.token
authPersist.value = persist
currentUser.value = data.user currentUser.value = data.user
setSyncFeedback('cloud', '클라우드 동기화 연결됨') setSyncFeedback('cloud', '클라우드 동기화 연결됨')
persistAuthState({ persistAuthState({
@@ -1351,19 +1586,48 @@ async function submitAuthForm() {
authMessage.value = '' authMessage.value = ''
try { try {
const result = if (authMode.value === 'reset-request') {
authMode.value === 'login' const result = await requestPasswordReset({
? await login({ email: authForm.email,
email: authForm.email, })
password: authForm.password, authMessage.value = result.resetPreviewUrl
}) ? `${result.message} 개발용 링크: ${result.resetPreviewUrl}`
: await signup({ : result.message
nickname: authForm.nickname, return
email: authForm.email, }
password: authForm.password,
})
await applyAuthSuccess(result, authMode.value === 'login' && authForm.rememberSession) if (authMode.value === 'reset-confirm') {
const result = await confirmPasswordReset({
token: authForm.resetToken,
newPassword: authForm.newPassword,
})
authMode.value = 'login'
authForm.password = ''
authForm.newPassword = ''
authForm.resetToken = ''
authMessage.value = result.message
return
}
const result = authMode.value === 'login'
? await login({
email: authForm.email,
password: authForm.password,
})
: await signup({
nickname: authForm.nickname,
email: authForm.email,
password: authForm.password,
})
if (authMode.value === 'signup') {
authMode.value = 'login'
authForm.password = ''
authMessage.value = result.message
return
}
await applyAuthSuccess(result, authForm.rememberSession)
} catch (error) { } catch (error) {
authMessage.value = toUserFacingApiError(error, '인증 처리 중 문제가 발생했습니다.') authMessage.value = toUserFacingApiError(error, '인증 처리 중 문제가 발생했습니다.')
} finally { } finally {
@@ -1379,6 +1643,7 @@ async function restoreAuthSession() {
} }
authToken.value = savedAuth.token authToken.value = savedAuth.token
authPersist.value = savedAuth.persist
currentUser.value = savedAuth.user ?? null currentUser.value = savedAuth.user ?? null
try { try {
@@ -1396,6 +1661,7 @@ async function restoreAuthSession() {
syncProfileForm() syncProfileForm()
} catch (error) { } catch (error) {
authToken.value = '' authToken.value = ''
authPersist.value = false
currentUser.value = null currentUser.value = null
setSyncFeedback('local', '로그인 후 클라우드 저장을 사용할 수 있습니다.', { setSyncFeedback('local', '로그인 후 클라우드 저장을 사용할 수 있습니다.', {
visible: false, visible: false,
@@ -1404,9 +1670,10 @@ async function restoreAuthSession() {
} }
} }
function logout() { function clearAuthenticatedState() {
clearPendingSyncTimers() clearPendingSyncTimers()
authToken.value = '' authToken.value = ''
authPersist.value = false
currentUser.value = null currentUser.value = null
goals.value = [] goals.value = []
goalQuery.value = '' goalQuery.value = ''
@@ -1414,9 +1681,11 @@ function logout() {
adminMessage.value = '' adminMessage.value = ''
adminUsers.value = [] adminUsers.value = []
adminRecentLogins.value = [] adminRecentLogins.value = []
accountDeleteMessage.value = ''
adminOverview.value = { adminOverview.value = {
totalUsers: 0, totalUsers: 0,
totalAdmins: 0, totalAdmins: 0,
disabledUsers: 0,
verifiedUsers: 0, verifiedUsers: 0,
activeUsers30d: 0, activeUsers30d: 0,
newUsers7d: 0, newUsers7d: 0,
@@ -1433,6 +1702,21 @@ function logout() {
restoreLocalPlannerRecords() restoreLocalPlannerRecords()
resetGoalForm() resetGoalForm()
resetPasswordForm() resetPasswordForm()
resetAccountDeleteForm()
}
async function logout() {
const token = authToken.value
if (token) {
try {
await logoutRequest(token)
} catch (error) {
console.warn('서버 로그아웃 처리에 실패했습니다.', error)
}
}
clearAuthenticatedState()
} }
async function loadAdminDashboard() { async function loadAdminDashboard() {
@@ -1458,6 +1742,71 @@ async function loadAdminDashboard() {
} }
} }
async function toggleAdminUserStatus(user) {
const willDisable = !user.disabledAt
const confirmed = window.confirm(
willDisable
? `"${user.nickname}" 계정을 비활성화할까요? 즉시 로그인할 수 없고 현재 세션도 종료됩니다.`
: `"${user.nickname}" 계정을 다시 사용할 수 있게 할까요?`,
)
if (!confirmed) {
return
}
adminActionUserId.value = user.id
try {
const result = await updateAdminUserStatus(authToken.value, user.id, willDisable)
adminMessage.value = result.message || '계정 상태를 변경했습니다.'
await loadAdminDashboard()
} catch (error) {
adminMessage.value = error.message || '계정 상태를 변경하지 못했습니다.'
} finally {
adminActionUserId.value = null
}
}
async function revokeAdminSessions(user) {
const confirmed = window.confirm(`"${user.nickname}" 사용자를 현재 로그인된 모든 기기에서 로그아웃시킬까요?`)
if (!confirmed) {
return
}
adminActionUserId.value = user.id
try {
const result = await revokeAdminUserSessions(authToken.value, user.id)
adminMessage.value = result.message || '사용자 세션을 정리했습니다.'
await loadAdminDashboard()
} catch (error) {
adminMessage.value = error.message || '사용자 세션을 종료하지 못했습니다.'
} finally {
adminActionUserId.value = null
}
}
async function removeAdminUser(user) {
const confirmed = window.confirm(`"${user.nickname}" 계정을 삭제할까요? 플래너 기록과 목표 데이터도 함께 삭제됩니다.`)
if (!confirmed) {
return
}
adminActionUserId.value = user.id
try {
const result = await deleteAdminUser(authToken.value, user.id)
adminMessage.value = result.message || '사용자 계정을 삭제했습니다.'
await loadAdminDashboard()
} catch (error) {
adminMessage.value = error.message || '사용자 계정을 삭제하지 못했습니다.'
} finally {
adminActionUserId.value = null
}
}
async function loadGoals() { async function loadGoals() {
if (!authToken.value) { if (!authToken.value) {
return return
@@ -1632,6 +1981,10 @@ function updatePasswordField({ field, value }) {
passwordForm[field] = value passwordForm[field] = value
} }
function updateAccountDeleteField({ field, value }) {
accountDeleteForm[field] = value
}
async function submitProfileForm() { async function submitProfileForm() {
profileBusy.value = true profileBusy.value = true
profileMessage.value = '' profileMessage.value = ''
@@ -1646,6 +1999,7 @@ async function submitProfileForm() {
persistAuthState({ persistAuthState({
token: authToken.value, token: authToken.value,
user: result.user, user: result.user,
persist: authPersist.value,
}) })
syncProfileForm() syncProfileForm()
await loadAdminDashboard() await loadAdminDashboard()
@@ -1688,6 +2042,36 @@ async function submitPasswordForm() {
} }
} }
async function submitDeleteAccount() {
if (!accountDeleteForm.currentPassword) {
accountDeleteMessage.value = '회원 탈퇴를 위해 현재 비밀번호를 입력해 주세요.'
return
}
const confirmed = window.confirm('정말로 회원 탈퇴하시겠습니까? 작성한 플래너와 목표 데이터도 함께 삭제됩니다.')
if (!confirmed) {
return
}
accountDeleteBusy.value = true
accountDeleteMessage.value = ''
try {
const result = await deleteAccount(authToken.value, {
currentPassword: accountDeleteForm.currentPassword,
})
resetAccountDeleteForm()
clearAuthenticatedState()
authMessage.value = ''
window.alert(result.message || '회원 탈퇴가 완료되었습니다.')
} catch (error) {
accountDeleteMessage.value = error.message || '회원 탈퇴를 처리하지 못했습니다.'
} finally {
accountDeleteBusy.value = false
}
}
function replacePlannerRecords(nextRecords) { function replacePlannerRecords(nextRecords) {
Object.keys(plannerRecords).forEach((key) => { Object.keys(plannerRecords).forEach((key) => {
delete plannerRecords[key] delete plannerRecords[key]
@@ -1700,16 +2084,8 @@ function replacePlannerRecords(nextRecords) {
function restoreLocalPlannerRecords() { function restoreLocalPlannerRecords() {
replacePlannerRecords(createInitialPlannerRecords(plannerSeed, normalizeRecord)) replacePlannerRecords(createInitialPlannerRecords(plannerSeed, normalizeRecord))
selectedDate.value = new Date()
const savedState = readPlannerStorageState() calendarViewDate.value = new Date(selectedDate.value)
if (savedState.selectedDate) {
selectedDate.value = toDateValue(savedState.selectedDate, selectedDate.value)
}
if (savedState.calendarViewDate) {
calendarViewDate.value = toDateValue(savedState.calendarViewDate, selectedDate.value)
}
} }
function findRecordKey(record) { function findRecordKey(record) {
@@ -1845,6 +2221,19 @@ async function printSelectedPlanner(layout = 'single') {
window.print() window.print()
} }
async function printPlannerRange() {
printLayout.value = printRangeLayout.value
applyPrintPageStyle(printRangeLayout.value)
await nextTick()
window.print()
}
async function initializeAppSession() {
await openVerificationFromUrl()
openPasswordResetFromUrl()
await restoreAuthSession()
}
onMounted(() => { onMounted(() => {
resetGoalForm() resetGoalForm()
setSyncFeedback('local', '로그인 후 클라우드 저장을 사용할 수 있습니다.', { setSyncFeedback('local', '로그인 후 클라우드 저장을 사용할 수 있습니다.', {
@@ -1853,7 +2242,7 @@ onMounted(() => {
updateWindowWidth() updateWindowWidth()
window.addEventListener('resize', updateWindowWidth) window.addEventListener('resize', updateWindowWidth)
window.addEventListener('keydown', handleGlobalKeydown) window.addEventListener('keydown', handleGlobalKeydown)
restoreAuthSession() initializeAppSession()
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
@@ -2058,16 +2447,9 @@ onBeforeUnmount(() => {
<button <button
type="button" type="button"
class="rounded-2xl border border-stone-200 px-4 py-3 text-xs font-bold tracking-[0.14em] text-stone-600 transition hover:border-stone-400 hover:text-ink" class="rounded-2xl border border-stone-200 px-4 py-3 text-xs font-bold tracking-[0.14em] text-stone-600 transition hover:border-stone-400 hover:text-ink"
@click="printSelectedPlanner('single')" @click="openPrintDialog"
> >
1 인쇄 PRINT
</button>
<button
type="button"
class="rounded-2xl border border-stone-200 px-4 py-3 text-xs font-bold tracking-[0.14em] text-stone-600 transition hover:border-stone-400 hover:text-ink"
@click="printSelectedPlanner('double')"
>
2 인쇄
</button> </button>
</div> </div>
</section> </section>
@@ -2097,9 +2479,6 @@ onBeforeUnmount(() => {
<div class="overflow-hidden rounded-[28px] border border-stone-200 bg-[linear-gradient(145deg,rgba(255,255,255,0.96),rgba(246,238,228,0.92))] p-5 shadow-[0_18px_40px_rgba(28,25,23,0.06)]"> <div class="overflow-hidden rounded-[28px] border border-stone-200 bg-[linear-gradient(145deg,rgba(255,255,255,0.96),rgba(246,238,228,0.92))] p-5 shadow-[0_18px_40px_rgba(28,25,23,0.06)]">
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
<p class="text-[11px] font-bold uppercase tracking-[0.28em] text-stone-500">10 Minute Planner</p> <p class="text-[11px] font-bold uppercase tracking-[0.28em] text-stone-500">10 Minute Planner</p>
<span class="rounded-full border border-stone-200 bg-white/80 px-3 py-1 text-[10px] font-bold tracking-[0.18em] text-stone-500">
DAILY FLOW
</span>
</div> </div>
<div class="mt-6 space-y-3"> <div class="mt-6 space-y-3">
<h1 class="text-[28px] font-semibold leading-[1.1] tracking-[-0.06em] text-stone-900"> <h1 class="text-[28px] font-semibold leading-[1.1] tracking-[-0.06em] text-stone-900">
@@ -2226,16 +2605,9 @@ onBeforeUnmount(() => {
<button <button
type="button" type="button"
class="rounded-2xl border border-stone-200 px-4 py-3 text-xs font-bold tracking-[0.14em] text-stone-600 transition hover:border-stone-400 hover:text-ink" class="rounded-2xl border border-stone-200 px-4 py-3 text-xs font-bold tracking-[0.14em] text-stone-600 transition hover:border-stone-400 hover:text-ink"
@click="printSelectedPlanner('single')" @click="openPrintDialog"
> >
1 인쇄 PRINT
</button>
<button
type="button"
class="rounded-2xl border border-stone-200 px-4 py-3 text-xs font-bold tracking-[0.14em] text-stone-600 transition hover:border-stone-400 hover:text-ink"
@click="printSelectedPlanner('double')"
>
2 인쇄
</button> </button>
</div> </div>
</section> </section>
@@ -2255,7 +2627,7 @@ onBeforeUnmount(() => {
10 MINITES PLANNER 10 MINITES PLANNER
</h2> </h2>
<p class="mx-auto mt-3 max-w-xl text-sm leading-7 text-stone-600 sm:text-base"> <p class="mx-auto mt-3 max-w-xl text-sm leading-7 text-stone-600 sm:text-base">
장기 목표, , 집중 시간, 짧은 코멘트를 장의 다이어리로 남기고 내일의 작업까지 이어가세요. 장기 목표, , 집중 시간, 짧은 코멘트를 장의 다이어리로 남기고<br />내일의 작업까지 이어가세요.
</p> </p>
</div> </div>
@@ -2796,13 +3168,19 @@ onBeforeUnmount(() => {
:password-busy="passwordBusy" :password-busy="passwordBusy"
:profile-message="profileMessage" :profile-message="profileMessage"
:password-message="passwordMessage" :password-message="passwordMessage"
:account-delete-form="accountDeleteForm"
:account-delete-busy="accountDeleteBusy"
:account-delete-message="accountDeleteMessage"
:auth-session-info="authSessionInfo"
:guide-tooltip-reset-message="guideTooltipResetMessage" :guide-tooltip-reset-message="guideTooltipResetMessage"
:carryover-check-policy="carryoverCheckPolicy" :carryover-check-policy="carryoverCheckPolicy"
@update:profile-field="updateProfileField" @update:profile-field="updateProfileField"
@update:password-field="updatePasswordField" @update:password-field="updatePasswordField"
@update:account-delete-field="updateAccountDeleteField"
@update:carryover-check-policy="updateCarryoverCheckPolicy" @update:carryover-check-policy="updateCarryoverCheckPolicy"
@submit:profile="submitProfileForm" @submit:profile="submitProfileForm"
@submit:password="submitPasswordForm" @submit:password="submitPasswordForm"
@submit:delete-account="submitDeleteAccount"
@reset-guide-tooltips="resetGuideTooltips" @reset-guide-tooltips="resetGuideTooltips"
/> />
@@ -2813,7 +3191,11 @@ onBeforeUnmount(() => {
:users="adminUsers" :users="adminUsers"
:recent-logins="adminRecentLogins" :recent-logins="adminRecentLogins"
:busy="adminBusy" :busy="adminBusy"
:action-busy-user-id="adminActionUserId"
:message="adminMessage" :message="adminMessage"
@toggle-user-status="toggleAdminUserStatus"
@revoke-user-sessions="revokeAdminSessions"
@delete-user="removeAdminUser"
/> />
<StatsDashboard <StatsDashboard
@@ -2832,79 +3214,35 @@ onBeforeUnmount(() => {
/> />
<section v-if="isAuthenticated" class="print-only"> <section v-if="isAuthenticated" class="print-only">
<div v-if="printLayout === 'single'" class="print-paper print-paper--single"> <div
<div class="print-sheet-frame"> v-for="(paper, paperIndex) in printPapers"
:key="`${printLayout}-${paperIndex}`"
class="print-paper"
:class="printLayout === 'single' ? 'print-paper--single' : 'print-paper--double'"
>
<div
v-for="page in paper"
:key="page.key"
class="print-sheet-frame"
>
<PlannerPage <PlannerPage
:date-main="selectedDateDisplay.main" :date-main="page.display.main"
:date-weekday="selectedDateDisplay.weekday" :date-weekday="page.display.weekday"
:date-weekday-tone="selectedDateDisplay.weekdayTone" :date-weekday-tone="page.display.weekdayTone"
:dday="plannerDday" :dday="page.dday"
:show-dday="showPlannerDday" :show-dday="Boolean(page.dday)"
:comment="planner.comment" :comment="page.record.comment"
:total-time="formatTotalTimeKorean(planner)" :total-time="formatTotalTimeKorean(page.record)"
:tasks="planner.tasks" :tasks="page.record.tasks"
:memo="planner.memo" :memo="page.record.memo"
:hours="hours" :hours="hours"
:timetable="planner.timetable" :timetable="page.record.timetable"
@update:comment="updateComment(planner, $event)"
@update:task-label="updateTaskLabel(planner, $event)"
@update:task-title="updateTaskTitle(planner, $event)"
@toggle:task="toggleTask(planner, $event)"
@clear:tasks="clearTasks(planner, $event)"
@update:memo-label="updateMemoLabel(planner, $event)"
@update:memo="updateMemo(planner, $event)"
@update:timetable="updateTimetable(planner, $event)"
/>
</div>
</div>
<div v-else class="print-paper print-paper--double">
<div class="print-sheet-frame">
<PlannerPage
:date-main="selectedDateDisplay.main"
:date-weekday="selectedDateDisplay.weekday"
:date-weekday-tone="selectedDateDisplay.weekdayTone"
:dday="plannerDday"
:show-dday="showPlannerDday"
:comment="planner.comment"
:total-time="formatTotalTimeKorean(planner)"
:tasks="planner.tasks"
:memo="planner.memo"
:hours="hours"
:timetable="planner.timetable"
@update:comment="updateComment(planner, $event)"
@update:task-label="updateTaskLabel(planner, $event)"
@update:task-title="updateTaskTitle(planner, $event)"
@toggle:task="toggleTask(planner, $event)"
@clear:tasks="clearTasks(planner, $event)"
@update:memo-label="updateMemoLabel(planner, $event)"
@update:memo="updateMemo(planner, $event)"
@update:timetable="updateTimetable(planner, $event)"
/>
</div>
<div class="print-sheet-frame">
<PlannerPage
:date-main="secondaryDateDisplay.main"
:date-weekday="secondaryDateDisplay.weekday"
:date-weekday-tone="secondaryDateDisplay.weekdayTone"
:dday="''"
:show-dday="false"
:comment="secondaryPlanner.comment"
:total-time="formatTotalTimeKorean(secondaryPlanner)"
:tasks="secondaryPlanner.tasks"
:memo="secondaryPlanner.memo"
:hours="hours"
:timetable="secondaryPlanner.timetable"
@update:comment="updateComment(secondaryPlanner, $event)"
@update:task-label="updateTaskLabel(secondaryPlanner, $event)"
@update:task-title="updateTaskTitle(secondaryPlanner, $event)"
@toggle:task="toggleTask(secondaryPlanner, $event)"
@clear:tasks="clearTasks(secondaryPlanner, $event)"
@update:memo-label="updateMemoLabel(secondaryPlanner, $event)"
@update:memo="updateMemo(secondaryPlanner, $event)"
@update:timetable="updateTimetable(secondaryPlanner, $event)"
/> />
</div> </div>
<div
v-if="printLayout === 'double' && paper.length === 1"
class="print-sheet-frame print-sheet-frame--blank"
/>
</div> </div>
</section> </section>
</div> </div>
@@ -2922,6 +3260,93 @@ onBeforeUnmount(() => {
@update:field="updateAuthField" @update:field="updateAuthField"
/> />
<div
v-if="printDialogOpen"
class="print-hidden fixed inset-0 z-50 flex items-center justify-center bg-stone-900/45 px-4 py-6 backdrop-blur-sm"
@click.self="closePrintDialog"
>
<section class="w-full max-w-lg rounded-[30px] border border-white/70 bg-[#f7f2ea] p-5 shadow-[0_24px_80px_rgba(28,25,23,0.2)] sm:p-6">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-[10px] font-bold uppercase tracking-[0.24em] text-stone-500">Print Planner</p>
<h2 class="mt-3 text-2xl font-semibold tracking-[-0.05em] text-stone-900">
출력할 날짜 범위 선택
</h2>
<p class="mt-3 text-sm font-semibold leading-6 text-stone-600">
선택한 기간을 1페이지씩 또는 2페이지씩 묶어서 바로 출력합니다.
</p>
</div>
<button
type="button"
class="rounded-full border border-stone-200 bg-white px-3 py-2 text-[10px] font-bold tracking-[0.14em] text-stone-500 transition hover:border-stone-400 hover:text-stone-900"
@click="closePrintDialog"
>
CLOSE
</button>
</div>
<div class="mt-6 grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-2 text-[11px] font-bold tracking-[0.14em] text-stone-500">
시작일
<input
v-model="printRangeStart"
type="date"
class="rounded-2xl border border-stone-200 bg-white px-4 py-3 text-sm font-semibold text-stone-800 outline-none transition focus:border-stone-400"
/>
</label>
<label class="flex flex-col gap-2 text-[11px] font-bold tracking-[0.14em] text-stone-500">
종료일
<input
v-model="printRangeEnd"
type="date"
class="rounded-2xl border border-stone-200 bg-white px-4 py-3 text-sm font-semibold text-stone-800 outline-none transition focus:border-stone-400"
/>
</label>
</div>
<div class="mt-5 grid gap-2 sm:grid-cols-2">
<button
type="button"
class="rounded-2xl px-4 py-3 text-xs font-bold tracking-[0.14em] transition"
:class="printRangeLayout === 'single' ? 'bg-stone-900 text-white' : 'border border-stone-200 bg-white text-stone-500'"
@click="printRangeLayout = 'single'"
>
1페이지씩
</button>
<button
type="button"
class="rounded-2xl px-4 py-3 text-xs font-bold tracking-[0.14em] transition"
:class="printRangeLayout === 'double' ? 'bg-stone-900 text-white' : 'border border-stone-200 bg-white text-stone-500'"
@click="printRangeLayout = 'double'"
>
2페이지씩
</button>
</div>
<div class="mt-5 rounded-2xl border border-stone-200 bg-white/78 px-4 py-3">
<p class="text-[11px] font-bold tracking-[0.12em] text-stone-500">출력 예정</p>
<p class="mt-1 text-sm font-semibold text-stone-900">{{ printPageCountLabel }}</p>
</div>
<div class="mt-6 grid gap-2 sm:grid-cols-[1fr_auto]">
<button
type="button"
class="rounded-full border border-stone-900 bg-stone-900 px-5 py-3 text-xs font-bold tracking-[0.16em] text-white transition hover:bg-stone-700"
@click="printPlannerRange"
>
PRINT
</button>
<button
type="button"
class="rounded-full border border-stone-300 bg-white px-5 py-3 text-xs font-bold tracking-[0.16em] text-stone-700 transition hover:border-stone-500 hover:text-stone-900"
@click="closePrintDialog"
>
취소
</button>
</div>
</section>
</div>
<div <div
v-if="carryoverCheckPrompt" v-if="carryoverCheckPrompt"
class="fixed inset-0 z-50 flex items-center justify-center bg-stone-900/45 px-4 py-6 backdrop-blur-sm" class="fixed inset-0 z-50 flex items-center justify-center bg-stone-900/45 px-4 py-6 backdrop-blur-sm"

View File

@@ -20,8 +20,18 @@ const props = defineProps({
type: String, type: String,
default: '', default: '',
}, },
actionBusyUserId: {
type: Number,
default: null,
},
}) })
const emit = defineEmits([
'toggle-user-status',
'revoke-user-sessions',
'delete-user',
])
function formatDate(value) { function formatDate(value) {
if (!value) { if (!value) {
return '기록 없음' return '기록 없음'
@@ -45,7 +55,7 @@ function formatDate(value) {
<template> <template>
<section class="grid gap-6"> <section class="grid gap-6">
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4"> <div class="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
<article class="rounded-[28px] border border-white/60 bg-white/80 p-6"> <article class="rounded-[28px] border border-white/60 bg-white/80 p-6">
<p class="text-[11px] font-bold uppercase tracking-[0.24em] text-stone-500">Total Users</p> <p class="text-[11px] font-bold uppercase tracking-[0.24em] text-stone-500">Total Users</p>
<p class="mt-4 text-4xl font-semibold tracking-[-0.06em] text-stone-900">{{ summary.totalUsers }}</p> <p class="mt-4 text-4xl font-semibold tracking-[-0.06em] text-stone-900">{{ summary.totalUsers }}</p>
@@ -69,6 +79,12 @@ function formatDate(value) {
<p class="mt-4 text-3xl font-semibold tracking-[-0.06em] text-stone-900">{{ summary.verifiedUsers }} / {{ summary.totalAdmins }}</p> <p class="mt-4 text-3xl font-semibold tracking-[-0.06em] text-stone-900">{{ summary.verifiedUsers }} / {{ summary.totalAdmins }}</p>
<p class="mt-2 text-sm font-semibold text-stone-500">인증 완료 계정 / 관리자 </p> <p class="mt-2 text-sm font-semibold text-stone-500">인증 완료 계정 / 관리자 </p>
</article> </article>
<article class="rounded-[28px] border border-white/60 bg-white/80 p-6">
<p class="text-[11px] font-bold uppercase tracking-[0.24em] text-stone-500">Disabled Users</p>
<p class="mt-4 text-4xl font-semibold tracking-[-0.06em] text-stone-900">{{ summary.disabledUsers ?? 0 }}</p>
<p class="mt-2 text-sm font-semibold text-stone-500">관리자가 비활성화한 계정 </p>
</article>
</div> </div>
<div class="grid gap-6 xl:grid-cols-[320px_minmax(0,1fr)]"> <div class="grid gap-6 xl:grid-cols-[320px_minmax(0,1fr)]">
@@ -128,7 +144,7 @@ function formatDate(value) {
</p> </p>
<div class="mt-5 overflow-hidden rounded-[24px] border border-stone-200 bg-white"> <div class="mt-5 overflow-hidden rounded-[24px] border border-stone-200 bg-white">
<div class="hidden grid-cols-[84px_minmax(0,1.4fr)_120px_150px_110px_100px_150px] gap-3 border-b border-stone-200 bg-[#f8f4ed] px-5 py-4 text-[10px] font-bold uppercase tracking-[0.18em] text-stone-500 xl:grid"> <div class="hidden grid-cols-[84px_minmax(0,1.2fr)_110px_150px_90px_90px_150px_190px] gap-3 border-b border-stone-200 bg-[#f8f4ed] px-5 py-4 text-[10px] font-bold uppercase tracking-[0.18em] text-stone-500 xl:grid">
<span>ID</span> <span>ID</span>
<span>사용자</span> <span>사용자</span>
<span>권한</span> <span>권한</span>
@@ -136,6 +152,7 @@ function formatDate(value) {
<span>문서 </span> <span>문서 </span>
<span>목표 </span> <span>목표 </span>
<span>상태</span> <span>상태</span>
<span>관리</span>
</div> </div>
<div class="divide-y divide-stone-200"> <div class="divide-y divide-stone-200">
@@ -144,7 +161,7 @@ function formatDate(value) {
:key="user.id" :key="user.id"
class="px-5 py-4" class="px-5 py-4"
> >
<div class="grid gap-3 xl:grid-cols-[84px_minmax(0,1.4fr)_120px_150px_110px_100px_150px] xl:items-center"> <div class="grid gap-3 xl:grid-cols-[84px_minmax(0,1.2fr)_110px_150px_90px_90px_150px_190px] xl:items-center">
<p class="text-xs font-bold tracking-[0.14em] text-stone-500">#{{ user.id }}</p> <p class="text-xs font-bold tracking-[0.14em] text-stone-500">#{{ user.id }}</p>
<div> <div>
<p class="text-sm font-semibold text-stone-900">{{ user.nickname }}</p> <p class="text-sm font-semibold text-stone-900">{{ user.nickname }}</p>
@@ -157,9 +174,9 @@ function formatDate(value) {
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<span <span
class="rounded-full px-3 py-1 text-[10px] font-bold tracking-[0.14em]" class="rounded-full px-3 py-1 text-[10px] font-bold tracking-[0.14em]"
:class="user.isActiveRecently ? 'bg-emerald-100 text-emerald-700' : 'bg-stone-100 text-stone-500'" :class="user.disabledAt ? 'bg-rose-100 text-rose-700' : user.isActiveRecently ? 'bg-emerald-100 text-emerald-700' : 'bg-stone-100 text-stone-500'"
> >
{{ user.isActiveRecently ? '활동 중' : '휴면 가능성' }} {{ user.disabledAt ? '비활성화' : user.isActiveRecently ? '활동 중' : '휴면 가능성' }}
</span> </span>
<span <span
class="rounded-full px-3 py-1 text-[10px] font-bold tracking-[0.14em]" class="rounded-full px-3 py-1 text-[10px] font-bold tracking-[0.14em]"
@@ -167,6 +184,48 @@ function formatDate(value) {
> >
{{ user.emailVerifiedAt ? '이메일 인증' : '미인증' }} {{ user.emailVerifiedAt ? '이메일 인증' : '미인증' }}
</span> </span>
<span
class="rounded-full px-3 py-1 text-[10px] font-bold tracking-[0.14em]"
:class="user.activeSessionCount > 0 ? 'bg-violet-100 text-violet-700' : 'bg-stone-100 text-stone-500'"
>
세션 {{ user.activeSessionCount }}
</span>
</div>
<div class="flex flex-wrap items-center gap-2 xl:justify-end">
<button
v-if="user.role !== 'admin'"
type="button"
class="rounded-full border px-3 py-2 text-[10px] font-bold tracking-[0.14em] transition"
:class="user.disabledAt ? 'border-emerald-300 text-emerald-700 hover:bg-emerald-50' : 'border-amber-300 text-amber-700 hover:bg-amber-50'"
:disabled="actionBusyUserId === user.id"
@click="emit('toggle-user-status', user)"
>
{{ user.disabledAt ? '다시 허용' : '비활성화' }}
</button>
<button
v-if="user.role !== 'admin'"
type="button"
class="rounded-full border border-stone-300 px-3 py-2 text-[10px] font-bold tracking-[0.14em] text-stone-700 transition hover:bg-stone-100"
:disabled="actionBusyUserId === user.id"
@click="emit('revoke-user-sessions', user)"
>
강제 로그아웃
</button>
<button
v-if="user.role !== 'admin'"
type="button"
class="rounded-full border border-rose-300 px-3 py-2 text-[10px] font-bold tracking-[0.14em] text-rose-700 transition hover:bg-rose-50"
:disabled="actionBusyUserId === user.id"
@click="emit('delete-user', user)"
>
삭제
</button>
<span
v-if="actionBusyUserId === user.id"
class="text-[10px] font-bold tracking-[0.12em] text-stone-400"
>
처리 ...
</span>
</div> </div>
</div> </div>
</article> </article>

View File

@@ -35,6 +35,58 @@ function updateField(field, event) {
value: event.target.type === 'checkbox' ? event.target.checked : event.target.value, value: event.target.type === 'checkbox' ? event.target.checked : event.target.value,
}) })
} }
function getTitle(mode) {
if (mode === 'signup') {
return '회원가입'
}
if (mode === 'reset-request') {
return '비밀번호 찾기'
}
if (mode === 'reset-confirm') {
return '새 비밀번호 설정'
}
return '로그인'
}
function getDescription(mode) {
if (mode === 'signup') {
return '기록을 저장할 계정을 만들어요.'
}
if (mode === 'reset-request') {
return '가입한 이메일로 재설정 링크를 받을 수 있습니다.'
}
if (mode === 'reset-confirm') {
return '메일로 받은 링크의 토큰과 새 비밀번호를 확인합니다.'
}
return '내 플래너를 이어서 열어요.'
}
function getSubmitLabel(mode, busy) {
if (busy) {
return '처리 중...'
}
if (mode === 'signup') {
return '가입하기'
}
if (mode === 'reset-request') {
return '재설정 링크 받기'
}
if (mode === 'reset-confirm') {
return '비밀번호 재설정'
}
return '로그인하기'
}
</script> </script>
<template> <template>
@@ -47,10 +99,10 @@ function updateField(field, event) {
<div> <div>
<p class="text-[10px] font-bold uppercase tracking-[0.24em] text-stone-500">10 Minute Planner</p> <p class="text-[10px] font-bold uppercase tracking-[0.24em] text-stone-500">10 Minute Planner</p>
<h2 class="mt-2 text-2xl font-semibold tracking-[-0.04em] text-stone-900"> <h2 class="mt-2 text-2xl font-semibold tracking-[-0.04em] text-stone-900">
{{ mode === 'login' ? '로그인' : '회원가입' }} {{ getTitle(mode) }}
</h2> </h2>
<p class="mt-2 text-sm leading-6 text-stone-600"> <p class="mt-2 text-sm leading-6 text-stone-600">
{{ mode === 'login' ? '내 플래너를 이어서 열어요.' : '기록을 저장할 계정을 만들어요.' }} {{ getDescription(mode) }}
</p> </p>
</div> </div>
<button <button
@@ -74,7 +126,7 @@ function updateField(field, event) {
/> />
</div> </div>
<div class="space-y-2"> <div v-if="mode !== 'reset-confirm'" class="space-y-2">
<label class="text-[11px] font-bold tracking-[0.16em] text-stone-600"> <label class="text-[11px] font-bold tracking-[0.16em] text-stone-600">
{{ mode === 'login' ? '이메일 또는 아이디' : '이메일' }} {{ mode === 'login' ? '이메일 또는 아이디' : '이메일' }}
</label> </label>
@@ -87,7 +139,18 @@ function updateField(field, event) {
/> />
</div> </div>
<div class="space-y-2"> <div v-if="mode === 'reset-confirm'" class="space-y-2">
<label class="text-[11px] font-bold tracking-[0.16em] text-stone-600">재설정 토큰</label>
<input
:value="form.resetToken"
type="text"
class="w-full rounded-2xl border border-stone-300 bg-white/90 px-4 py-3 text-sm font-semibold text-stone-800 outline-none transition focus:border-stone-500"
placeholder="메일 링크의 토큰"
@input="updateField('resetToken', $event)"
/>
</div>
<div v-if="mode === 'login' || mode === 'signup'" class="space-y-2">
<label class="text-[11px] font-bold tracking-[0.16em] text-stone-600">비밀번호</label> <label class="text-[11px] font-bold tracking-[0.16em] text-stone-600">비밀번호</label>
<input <input
:value="form.password" :value="form.password"
@@ -98,6 +161,17 @@ function updateField(field, event) {
/> />
</div> </div>
<div v-if="mode === 'reset-confirm'" class="space-y-2">
<label class="text-[11px] font-bold tracking-[0.16em] text-stone-600"> 비밀번호</label>
<input
:value="form.newPassword"
type="password"
class="w-full rounded-2xl border border-stone-300 bg-white/90 px-4 py-3 text-sm font-semibold text-stone-800 outline-none transition focus:border-stone-500"
placeholder="8자 이상 입력해 주세요."
@input="updateField('newPassword', $event)"
/>
</div>
<label <label
v-if="mode === 'login'" v-if="mode === 'login'"
class="-mt-1 flex items-center gap-2 px-1 text-left" class="-mt-1 flex items-center gap-2 px-1 text-left"
@@ -123,13 +197,22 @@ function updateField(field, event) {
class="w-full rounded-full bg-stone-900 px-5 py-3 text-xs font-bold tracking-[0.18em] text-white transition hover:bg-stone-700 disabled:cursor-not-allowed disabled:bg-stone-400" class="w-full rounded-full bg-stone-900 px-5 py-3 text-xs font-bold tracking-[0.18em] text-white transition hover:bg-stone-700 disabled:cursor-not-allowed disabled:bg-stone-400"
:disabled="busy" :disabled="busy"
> >
{{ busy ? '처리 중...' : mode === 'login' ? '로그인하기' : '가입하기' }} {{ getSubmitLabel(mode, busy) }}
</button> </button>
</form> </form>
<button
v-if="mode === 'login'"
type="button"
class="mt-4 w-full text-center text-xs font-bold tracking-[0.14em] text-stone-500 underline underline-offset-4 transition hover:text-stone-900"
@click="emit('switch-mode', 'reset-request')"
>
비밀번호를 잊으셨나요?
</button>
<div class="mt-5 flex items-center justify-center gap-2 border-t border-stone-300/70 pt-4"> <div class="mt-5 flex items-center justify-center gap-2 border-t border-stone-300/70 pt-4">
<p class="text-sm font-semibold text-stone-600"> <p class="text-sm font-semibold text-stone-600">
{{ mode === 'login' ? '아직 계정이 나요?' : '이미 계정이 있나요?' }} {{ mode === 'signup' ? '이미 계정이 나요?' : '계정 화면으로 돌아갈까요?' }}
</p> </p>
<button <button
type="button" type="button"

View File

@@ -14,6 +14,14 @@ const props = defineProps({
type: Object, type: Object,
required: true, required: true,
}, },
accountDeleteForm: {
type: Object,
required: true,
},
authSessionInfo: {
type: Object,
required: true,
},
profileBusy: { profileBusy: {
type: Boolean, type: Boolean,
default: false, default: false,
@@ -30,6 +38,14 @@ const props = defineProps({
type: String, type: String,
default: '', default: '',
}, },
accountDeleteBusy: {
type: Boolean,
default: false,
},
accountDeleteMessage: {
type: String,
default: '',
},
guideTooltipResetMessage: { guideTooltipResetMessage: {
type: String, type: String,
default: '', default: '',
@@ -45,6 +61,8 @@ const emit = defineEmits([
'update:password-field', 'update:password-field',
'submit:profile', 'submit:profile',
'submit:password', 'submit:password',
'update:account-delete-field',
'submit:delete-account',
'reset-guide-tooltips', 'reset-guide-tooltips',
'update:carryover-check-policy', 'update:carryover-check-policy',
]) ])
@@ -66,6 +84,13 @@ function updatePasswordField(field, event) {
value: event.target.value, value: event.target.value,
}) })
} }
function updateAccountDeleteField(field, event) {
emit('update:account-delete-field', {
field,
value: event.target.value,
})
}
</script> </script>
<template> <template>
@@ -130,6 +155,28 @@ function updatePasswordField(field, event) {
</button> </button>
</div> </div>
</div> </div>
<div class="mt-4 rounded-[24px] border border-stone-200 bg-white/80 p-4">
<p class="text-[10px] font-bold tracking-[0.18em] text-stone-500">LOGIN STATUS</p>
<div class="mt-3 space-y-3">
<div class="rounded-2xl border border-stone-200 bg-[#fbf7f0] px-4 py-3">
<p class="text-[10px] font-bold tracking-[0.16em] text-stone-500">현재 기기 저장 방식</p>
<p class="mt-2 text-sm font-semibold leading-6 text-stone-800">{{ authSessionInfo.storageLabel }}</p>
<p class="mt-1 text-xs font-semibold leading-5 text-stone-500">{{ authSessionInfo.storageDescription }}</p>
</div>
<div class="rounded-2xl border border-stone-200 bg-[#fbf7f0] px-4 py-3">
<p class="text-[10px] font-bold tracking-[0.16em] text-stone-500">최근 로그인</p>
<p class="mt-2 text-sm font-semibold leading-6 text-stone-800">{{ authSessionInfo.lastLoginLabel }}</p>
</div>
<div
v-if="authSessionInfo.verificationLabel"
class="rounded-2xl border border-stone-200 bg-[#fbf7f0] px-4 py-3"
>
<p class="text-[10px] font-bold tracking-[0.16em] text-stone-500">이메일 인증</p>
<p class="mt-2 text-sm font-semibold leading-6 text-stone-800">{{ authSessionInfo.verificationLabel }}</p>
</div>
</div>
</div>
</aside> </aside>
<div class="grid gap-6"> <div class="grid gap-6">
@@ -219,6 +266,42 @@ function updatePasswordField(field, event) {
{{ passwordBusy ? '변경 중...' : '비밀번호 변경' }} {{ passwordBusy ? '변경 중...' : '비밀번호 변경' }}
</button> </button>
</form> </form>
<form
v-if="user.role !== 'admin'"
class="rounded-[28px] border border-rose-200 bg-white/75 p-6"
@submit.prevent="emit('submit:delete-account')"
>
<p class="text-[11px] font-bold uppercase tracking-[0.24em] text-rose-500">Delete Account</p>
<p class="mt-4 text-sm font-semibold leading-6 text-stone-700">
회원 탈퇴를 진행하면 플래너 기록, 목표, 계정 정보가 함께 삭제됩니다.
</p>
<div class="mt-5 space-y-2">
<label class="text-[11px] font-bold tracking-[0.16em] text-stone-600">현재 비밀번호 확인</label>
<input
:value="accountDeleteForm.currentPassword"
type="password"
class="w-full rounded-2xl border border-rose-200 bg-white px-4 py-3 text-sm font-semibold text-stone-800 outline-none transition focus:border-rose-400"
@input="updateAccountDeleteField('currentPassword', $event)"
/>
</div>
<p
v-if="accountDeleteMessage"
class="mt-4 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-semibold leading-6 text-rose-700"
>
{{ accountDeleteMessage }}
</p>
<button
type="submit"
class="mt-5 rounded-full border border-rose-500 px-5 py-3 text-xs font-bold tracking-[0.18em] text-rose-600 transition hover:bg-rose-500 hover:text-white disabled:cursor-not-allowed disabled:border-rose-200 disabled:text-rose-300"
:disabled="accountDeleteBusy"
>
{{ accountDeleteBusy ? '처리 중...' : '회원 탈퇴' }}
</button>
</form>
</div> </div>
</section> </section>
</template> </template>

View File

@@ -1,10 +1,14 @@
import { buildApiUrl, toUserFacingApiError } from './apiBase' import { buildApiUrl, toUserFacingApiError } from './apiBase'
async function request(path, token) { async function request(path, token, { method = 'GET', body } = {}) {
const hasBody = body !== undefined
const response = await fetch(buildApiUrl(path), { const response = await fetch(buildApiUrl(path), {
method,
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
...(hasBody ? { 'Content-Type': 'application/json' } : {}),
}, },
body: hasBody ? JSON.stringify(body) : undefined,
}) })
const data = await response.json().catch(() => ({})) const data = await response.json().catch(() => ({}))
@@ -19,3 +23,22 @@ async function request(path, token) {
export async function fetchAdminOverview(token) { export async function fetchAdminOverview(token) {
return request('/api/admin/overview', token) return request('/api/admin/overview', token)
} }
export async function updateAdminUserStatus(token, userId, disabled) {
return request(`/api/admin/users/${userId}/status`, token, {
method: 'PUT',
body: { disabled },
})
}
export async function revokeAdminUserSessions(token, userId) {
return request(`/api/admin/users/${userId}/revoke-sessions`, token, {
method: 'POST',
})
}
export async function deleteAdminUser(token, userId) {
return request(`/api/admin/users/${userId}`, token, {
method: 'DELETE',
})
}

View File

@@ -92,6 +92,13 @@ export async function fetchCurrentUser(token) {
}) })
} }
export async function logout(token) {
return request('/api/auth/logout', {
method: 'POST',
token,
})
}
export async function updateProfile(token, { email, nickname }) { export async function updateProfile(token, { email, nickname }) {
return request('/api/auth/profile', { return request('/api/auth/profile', {
method: 'PUT', method: 'PUT',
@@ -107,3 +114,32 @@ export async function updatePassword(token, { currentPassword, newPassword }) {
body: { currentPassword, newPassword }, body: { currentPassword, newPassword },
}) })
} }
export async function deleteAccount(token, { currentPassword }) {
return request('/api/auth/account', {
method: 'DELETE',
token,
body: { currentPassword },
})
}
export async function requestPasswordReset({ email }) {
return request('/api/auth/password-reset/request', {
method: 'POST',
body: { email },
})
}
export async function confirmPasswordReset({ token, newPassword }) {
return request('/api/auth/password-reset/confirm', {
method: 'POST',
body: { token, newPassword },
})
}
export async function confirmVerification({ token }) {
return request('/api/auth/verification/confirm', {
method: 'POST',
body: { token },
})
}

View File

@@ -40,16 +40,8 @@ export function restorePlannerUiState({
toDateValue, toDateValue,
}) { }) {
const savedState = readStorageState() const savedState = readStorageState()
selectedDate.value = toDateValue(new Date(), selectedDate.value)
if (savedState.selectedDate) { calendarViewDate.value = new Date(selectedDate.value)
selectedDate.value = toDateValue(savedState.selectedDate, selectedDate.value)
}
if (savedState.calendarViewDate) {
calendarViewDate.value = toDateValue(savedState.calendarViewDate, selectedDate.value)
} else {
calendarViewDate.value = new Date(selectedDate.value)
}
if (savedState.statsRangeStart) { if (savedState.statsRangeStart) {
statsRangeStart.value = savedState.statsRangeStart statsRangeStart.value = savedState.statsRangeStart
@@ -75,12 +67,13 @@ export function persistPlannerState({
const previousState = readStorageState() const previousState = readStorageState()
const nextState = { const nextState = {
...previousState, ...previousState,
selectedDate: selectedDate.toISOString(),
calendarViewDate: calendarViewDate.toISOString(),
statsRangeStart, statsRangeStart,
statsRangeEnd, statsRangeEnd,
} }
delete nextState.selectedDate
delete nextState.calendarViewDate
if (includeRecords) { if (includeRecords) {
nextState.records = Object.fromEntries( nextState.records = Object.fromEntries(
Object.entries(plannerRecords).map(([key, record]) => [ Object.entries(plannerRecords).map(([key, record]) => [

View File

@@ -31,7 +31,7 @@
background: #ffffff !important; background: #ffffff !important;
width: auto !important; width: auto !important;
height: auto !important; height: auto !important;
overflow: hidden !important; overflow: visible !important;
} }
body { body {
@@ -50,17 +50,11 @@
} }
.print-only { .print-only {
display: flex !important; display: block !important;
width: 100% !important; width: 100% !important;
height: 100% !important; height: auto !important;
margin: 0 !important; margin: 0 !important;
padding: 0 !important; padding: 0 !important;
align-items: center;
justify-content: center;
break-inside: avoid-page;
page-break-inside: avoid;
break-after: avoid-page;
page-break-after: avoid;
} }
.print-root { .print-root {
@@ -79,15 +73,24 @@
justify-content: center; justify-content: center;
background: #ffffff !important; background: #ffffff !important;
padding: 0; padding: 0;
margin: 0 auto;
overflow: hidden; overflow: hidden;
break-after: page;
page-break-after: always;
break-inside: avoid-page;
page-break-inside: avoid;
}
.print-paper:last-child {
break-after: avoid-page;
page-break-after: avoid;
} }
body[data-print-layout='single'] .print-paper { body[data-print-layout='single'] .print-paper {
width: 204mm; width: 210mm;
height: 291mm; height: 297mm;
align-items: flex-start; align-items: center;
justify-content: center; justify-content: center;
padding-top: 3mm;
} }
.print-paper--double { .print-paper--double {
@@ -99,8 +102,8 @@
} }
body[data-print-layout='double'] .print-paper { body[data-print-layout='double'] .print-paper {
width: 285mm; width: 297mm;
height: 196mm; height: 210mm;
} }
body[data-print-layout='single'] .print-paper--single .print-sheet-frame { body[data-print-layout='single'] .print-paper--single .print-sheet-frame {
@@ -116,6 +119,10 @@
background: #ffffff !important; background: #ffffff !important;
} }
.print-sheet-frame--blank {
background: #ffffff !important;
}
body[data-print-layout='double'] .print-paper--double .print-sheet-frame { body[data-print-layout='double'] .print-paper--double .print-sheet-frame {
width: 139mm; width: 139mm;
height: 196mm; height: 196mm;