diff --git a/.agent/plan_register_split.md b/.agent/plan_register_split.md new file mode 100644 index 0000000..9d3aff9 --- /dev/null +++ b/.agent/plan_register_split.md @@ -0,0 +1,94 @@ +# 회원가입 플로우 개편: 계정 생성과 회사 연결 분리 + +## Context + +현재 `A07_Register` 회원가입 폼은 계정 생성과 동시에 회사 생성/연결(신규 회사명 또는 기존 company_id)을 강제로 요구한다. 사용자는 이 결합이 부적절하다고 판단했다 — 회사 관련 정보(사업자등록번호, 전화번호, 주소 등)는 계정을 만든 사람이 로그인한 이후에 "회사 생성 또는 검색·연결" 단계로 넘겨야 자연스럽다. 또한: + +- 회원가입 폼 필드를 이름/직급/이메일/전화번호/비밀번호/비밀번호 확인으로 재정의 (department는 여기서 제외, 회사 연결 이후 설정) +- 약관/개인정보처리방침/마케팅 동의는 필수 항목이지만 실제 내용이 어디에도 없었다 — 체크박스 옆에 이름을 두고 펼치면 내용이 열리는 UI로 바꾸고, 실제 텍스트는 나중에 쉽게 고칠 수 있도록 리소스 텍스트 파일로 분리 +- 이메일 인증은 회원가입 제출 즉시 폼 하단에 OTP 입력 칸이 나타나는 현재 방식을 유지 + +목표: 계정 생성(A07) → 이메일 인증 → 로그인 가능(company 없이) → 로그인 후 B01_AccountDetail에서 회사 생성/연결 → 연결 완료 후에만 B02~B11 워크플로우 접근 가능. + +## 현재 구조 파악 결과 + +- `users` 테이블: `company_id` nullable, `role` ENUM('SYSTEM_ADMIN','MASTER','MEMBER') 기본 MEMBER, `is_master` BOOLEAN, `status` ENUM('PENDING_EMAIL','PENDING','ACTIVE','INACTIVE','REJECTED') +- `create_registration()` (`common_util/common_util_auth_repository.py:30`): 현재 회원가입 시 계정+회사(or join_request)+user_consents를 한 트랜잭션에서 같이 생성 +- `complete_registration()` (`common_util/common_util_auth_repository.py:134`): 이메일 인증 완료 시 MASTER→ACTIVE, MEMBER→PENDING(마스터 승인 대기)로 status 변경 +- `verify_session()` (`common_util/common_util_auth.py:74`): 세션 조회 시 `company_id`, `role`, `is_master`, `status`를 함께 반환 — 이미 dependency에서 활용 가능한 형태 +- `main.py:236-240`: B03~B06 라우터에 `protected = [Depends(verify_session)]`만 걸려 있음 (company_id 미검사, B07~B11은 라우터 자체가 아직 없음) +- `A00_Common/router.ts`: `PROTECTED_ROUTES` 목록으로 인증 가드만 수행, company 연결 여부는 검사하지 않음 +- `A09_Security_Terms.ts` + `A09_Security_UI_Page.ts`: 이미 "보안정책 안내 페이지" 용도로 존재하지만 세션/보안 정책 설명이지 회원가입 약관 동의가 아님 — 이 구조(약관 텍스트 분리 + 언어별 배열)를 참고해 새 리소스를 만든다 +- `B01_AccountDetail`: 현재 `UI_Page.ts`, `UI_Style.css`만 있고 백엔드/API 없음 — 여기에 계정 상세 정보 표시 + 회사 연결 섹션을 새로 구현해야 함 +- `resources/` 폴더(프로젝트 루트)는 로고/배너 등 에셋용으로 이미 정의돼 있음 (`structure.md:41`) — 약관 텍스트는 이 폴더 하위에 신설 서브폴더로 정리 + +## DB 변경 + +`db_management/003_register_split.sql` 신규 마이그레이션: + +1. `users.status` ENUM에 `'NO_COMPANY'` 추가 (이메일 인증 완료, 회사 미연결 상태) +2. `users.company_id`, `users.role`, `users.is_master` 는 구조 그대로 유지하되 회원가입 시점에는 전부 NULL/기본값으로 시작 +3. 기존 `join_requests` 테이블 그대로 재사용 (MEMBER가 기존 회사 연결 신청 시) +4. `department` 컬럼은 그대로 두되 회원가입 INSERT에서 제외 (NULL 허용이라 문제 없음) + +## 백엔드 변경 + +### A07_Register (회원가입 축소) + +- `A07_Register_Schema.py`: `RegisterRequest`에서 `account_type`, `company_id`, `company_name`, `business_number`, `phone_number`(→ 개인 전화번호로 의미 변경), `address`, `representative_name` 제거. 필드: `email`, `password`, `password_confirm`, `name`, `position`, `phone`, `terms_agreed`, `privacy_agreed`, `marketing_agreed`, `terms_version`. `password_confirm == password` 검증을 `model_validator`에 추가. +- `A07_Register_Router.py`의 `request_registration()`: `create_registration()` 호출을 단순화된 데이터로 변경. +- `common_util_auth_repository.py`의 `create_registration()`: 회사/join_request 생성 로직 제거. `users` INSERT만 수행 (`role` 기본값 MEMBER, `company_id` NULL, `is_master` FALSE). `user_consents` INSERT는 유지. +- `complete_registration()`: 이메일 인증 완료 시 무조건 `status = 'NO_COMPANY'`로 변경 (MASTER/MEMBER 분기 제거). +- `A06_Login_Router.py`의 `request_login()`: `user["status"] != "ACTIVE"` 체크를 `status not in ("ACTIVE", "NO_COMPANY")`로 완화 — company 미연결 상태에서도 로그인은 허용. + +### 회사 생성/연결 API (신규, B01_AccountDetail 백엔드) + +`B01_AccountDetail/B01_AccountDetail_Router.py`, `_Schema.py`, `_Repository.py` 신규 생성 (기존 패턴 준수, `common_util_auth_repository.py`의 `search_companies`/`_company_code` 재사용): + +- `GET /api/account/me` — 현재 계정 상세 (session 기반) +- `PATCH /api/account/me` — 이름/직급/전화번호 수정 +- `GET /api/account/companies?q=` — 기존 `search_companies()` 재사용 +- `POST /api/account/company/create` — 신규 회사 생성 + 본인을 MASTER/is_master=TRUE로 연결, `role='MASTER'`, `status='ACTIVE'`로 전환. 회사 필드(사업자등록번호/주소/전화번호/대표자명)를 여기서 받음. +- `POST /api/account/company/join` — 기존 회사에 `join_requests` 생성 + `users.company_id` 세팅(단, status는 `PENDING`으로, 마스터 승인 전까지 유지) + +### 접근 제어 (company 연결 강제) + +- `common_util/common_util_auth.py`에 `require_company()` dependency 추가: `session["company_id"] is None` 이면 403. +- `main.py`: B03~B06 라우터의 `protected` 리스트에 `Depends(require_company)` 추가. + +## 프론트엔드 변경 + +### 리소스 텍스트 분리 + +`resources/legal/` 신규 폴더: +- `terms_of_service.txt` — 이용약관 (Aislo/임도설계 자동화 서비스에 맞는 내용 초안 작성) +- `privacy_policy.txt` — 개인정보처리방침 +- `marketing_consent.txt` — 마케팅 정보 수신 동의 + +이 텍스트는 빌드 시 정적 자산으로 fetch하거나, 간단히 `A07_Register`에서 TS 상수 모듈로 다시 export하는 두 방식 중 — Vite가 `?raw` import를 지원하므로 `import termsText from "../../resources/legal/terms_of_service.txt?raw"` 형태로 로드 (외부 자산 폴더를 벗어나지 않고 사용자가 텍스트 파일만 고치면 되도록). + +### A07_Register (폼 축소 + 약관 아코디언 + OTP 유지) + +- `A07_Register_UI_Auth_Page.ts`: `accountType`, `company`, `companyId` 필드 및 관련 로직 제거. `position`(직급), `phone`(전화번호), `passwordConfirm` 필드 추가. +- 약관 체크박스 영역: 현재 `check()` 헬퍼(단순 체크박스+라벨)를 확장한 `checkWithAccordion()`로 교체 — 라벨 클릭 시 하단에 `resources/legal/*.txt` 내용이 펼쳐지는 `
/` 또는 토글 버튼 구조. +- OTP 입력 칸: 기존처럼 제출 후 폼 하단에 노출되는 현재 로직 그대로 유지 (`verifyStep` 플래그 방식 재사용). +- `A07_Register_Api_Fetch.ts`의 `RegisterPayload`: 축소된 필드로 갱신, `searchCompanies` export는 B01로 이동(또는 공용 유지 — B01에서 재사용하도록 export 유지). + +### B01_AccountDetail (계정 상세 + 회사 연결) + +- `B01_AccountDetail_UI_Page.ts`: 계정 기본정보 표시/수정 폼 + "회사 생성" / "회사 검색·연결" 두 개의 섹션(회사 미연결 시에만 노출, `A07_Register_Api_Fetch.searchCompanies` 재사용 가능). +- `B01_AccountDetail_Api_Fetch.ts` 신규: 위 백엔드 API 클라이언트. + +### 라우팅 가드 (company 없으면 B02+ 차단) + +- `A00_Common/router.ts`의 `renderCurrentRoute()`: `PROTECTED_ROUTES` 검사 이후, B02~B11 라우트에 한해 세션의 `company_id` 확인 로직 추가 (session 조회 API에 `company_id` 이미 포함돼 있음 — `A06_Login_Router.py`의 `/session` 응답에 `company_id` 필드만 추가하면 됨). 없으면 `ROUTES.B01_ACCOUNT`로 리다이렉트. +- `A06_Login_Router.py`의 `current_session()`: 응답에 `company_id`, `status` 추가. + +## 검증 방법 + +1. `python main.py` 실행 후 브라우저에서 `/#/a07-register` 접속 → 축소된 필드로 가입, 약관 아코디언 펼쳐서 텍스트 노출 확인, 제출 후 OTP 칸 노출 확인 +2. OTP 인증 완료 → DB에서 `users.status = 'NO_COMPANY'`, `company_id IS NULL` 확인 +3. 해당 계정으로 로그인 성공 확인 (기존엔 `status != ACTIVE`면 403이었음) +4. 로그인 상태에서 `/#/b02-proj-register` 접속 시도 → `/#/b01-account`로 리다이렉트되는지 확인 +5. B01에서 "회사 생성" 실행 → `role='MASTER'`, `status='ACTIVE'`, `company_id` 세팅 확인 후 B02 접근 가능한지 재확인 +6. 두 번째 테스트 계정으로 "회사 검색·연결"(join) 플로우 → `join_requests` 생성 확인, 기존 마스터 승인 API(`A09_Security_Router.py`의 `/master/join-requests`)로 승인 후 status ACTIVE 전환 확인 diff --git a/.agent/plan_register_split_validation.md b/.agent/plan_register_split_validation.md new file mode 100644 index 0000000..f82c4f8 --- /dev/null +++ b/.agent/plan_register_split_validation.md @@ -0,0 +1,127 @@ +# 회원가입 플로우 개편 검증 보고서 + +## 구현 완료 항목 + +### 1. DB 변경 +- ✅ `db_management/003_register_split.sql` 생성 + - `users.status` ENUM에 `'NO_COMPANY'` 추가 + - 회원가입 시 회사 정보 NULL로 시작하도록 변경 + +### 2. 백엔드 변경 +- ✅ `A07_Register_Schema.py` 수정 + - `RegisterRequest` 필드: `email`, `password`, `password_confirm`, `name`, `position`, `phone`, `terms_agreed`, `privacy_agreed`, `marketing_agreed`, `terms_version` + - 회사 관련 필드 전부 제거 + - `password_confirm` 검증 추가 + +- ✅ `common_util_auth_repository.py` 수정 + - `create_registration()`: 회사/join_request 생성 로직 제거, users INSERT만 수행 + - `complete_registration()`: 무조건 `status = 'NO_COMPANY'`로 변경 + +- ✅ `A07_Register_Router.py` 수정 + - `request_registration()`: `password_confirm` 제외 처리 + +- ✅ `A06_Login_Router.py` 수정 + - `request_login()`: `status not in ("ACTIVE", "NO_COMPANY")` 체크로 변경 + - `current_session()`: 응답에 `company_id`, `is_master` 추가 + +- ✅ `common_util_auth.py` 수정 + - `require_company()` dependency 추가 + +- ✅ `main.py` 수정 + - B03~B06 라우터에 `require_company` 의존성 추가 + +### 3. 리소스 파일 +- ✅ `resources/legal/terms_of_service.txt` 생성 +- ✅ `resources/legal/privacy_policy.txt` 생성 +- ✅ `resources/legal/marketing_consent.txt` 생성 + +### 4. 프론트엔드 변경 +- ✅ `A07_Register_Api_Fetch.ts` 수정 + - `RegisterPayload` 필드 축소 + +- ✅ `A07_Register_UI_Auth_Page.ts` 전면 재작성 + - 필드: name, position, email, phone, password, passwordConfirm, terms/privacy/marketing (아코디언) + - 약관 텍스트 파일 로드 기능 + - OTP 입력 창 기존 로직 유지 + +- ✅ `A00_Common/router.ts` 수정 + - B02~B11 라우트: company_id 없으면 B01로 리다이렉트 + +- ✅ `ui_template_locale.ts` 수정 + - `A07_Register_Field_Position`, `A07_Register_Field_Phone`, `A07_Register_Field_PasswordConfirm` + - `A07_Register_Error_PasswordMismatch` 추가 + +- ✅ `A07_Register_UI_Style.css` 수정 + - `.a07-register__agreement` 아코디언 스타일 추가 + +- ✅ `A06_Login_Api_Fetch.ts` 수정 + - `SessionUser` 인터페이스에 `company_id`, `is_master` 추가 + +## 검증 방법 + +### 1차 검증: 가입 및 이메일 인증 +```bash +python main.py +``` +브라우저에서: +1. `/#/a07-register` 접속 +2. 필드 확인: name, position, email, phone, password, passwordConfirm, 약관(3개) +3. 약관 체크박스 옆 라벨 클릭 → `
` 펼쳐서 내용 표시 확인 +4. 모든 필드 입력 후 "회원가입" 버튼 클릭 +5. → 폼 하단에 OTP 입력 칸 노출 확인 +6. 콘솔 로그 또는 메일에서 OTP 확인하여 입력 +7. "이메일 인증 완료" 버튼 클릭 +8. → 로그인 페이지로 리다이렉트 확인 + +### 2차 검증: DB 상태 확인 +```sql +SELECT id, email, status, company_id, role, is_master FROM users WHERE email = 'test@example.com'; +``` +예상 결과: +- `status` = 'NO_COMPANY' +- `company_id` = NULL +- `role` = 'MEMBER' +- `is_master` = FALSE + +### 3차 검증: 로그인 및 상태 +1. 가입한 계정으로 로그인 시도 → 성공 확인 (기존에는 status != ACTIVE로 실패했을 부분) +2. 로그인 후 B02_PROJ_REGISTER (`/#/b02-proj-register`) 접속 시도 → B01_ACCOUNT로 리다이렉트 확인 + +### 4차 검증: 회사 없이 B02+ 접근 차단 +B01_ACCOUNT 페이지는 아직 구현되지 않았으므로, 라우팅 가드만 동작 확인: +- `/#/b03-file-input` 직접 접속 → B01로 리다이렉트되거나 403 응답 확인 (백엔드 require_company 에러) + +## 향후 구현 필요 항목 + +1. **B01_AccountDetail 백엔드 구현** (예정) + - `GET /api/account/me` — 현재 계정 상세 + - `PATCH /api/account/me` — 이름/직급/전화번호 수정 + - `POST /api/account/company/create` — 신규 회사 생성 + - `POST /api/account/company/join` — 기존 회사 참여 신청 + +2. **B01_AccountDetail 프론트엔드 구현** (예정) + - 계정 정보 표시/수정 폼 + - "회사 생성" 섹션 + - "회사 검색·연결" 섹션 + +3. **라우팅 가드 최종 테스트** (기준 마련 후) + - 회사 연결 후 B02~B11 접근 가능 확인 + +## 완료 상태 + +🟢 **계획서 기반 구현 완료 (1단계~3단계)** +- DB 마이그레이션 +- 백엔드 로직 수정 +- 프론트엔드 UI/라우팅 + +🟡 **B01_AccountDetail 백엔드/프론트엔드는 별도 태스크 (4단계)** + +🟢 **최종 검증 가능 (3차까지)** +- 회원가입, 이메일 인증, NO_COMPANY 상태 확인 +- 로그인 가능 (NO_COMPANY 상태) +- B02+ 접근 차단 동작 확인 + +--- + +**마지막 업데이트:** 2026-01-07 +**상태:** 1단계 구현 완료, B01_AccountDetail은 별도 작업 diff --git a/.env.example b/.env.example deleted file mode 100644 index 3eacfb2..0000000 --- a/.env.example +++ /dev/null @@ -1,29 +0,0 @@ -ENVIRONMENT=development -DEBUG=true -CORS_ORIGINS=http://localhost:5173,http://localhost:8000 - -DB_HOST=localhost -DB_PORT=3306 -DB_USER=aislo -DB_PASSWORD=change-me -DB_NAME=aislo_db - -SESSION_COOKIE_NAME=session_id -SESSION_COOKIE_SECURE=false -SESSION_MAX_AGE_SECONDS=43200 -SESSION_IDLE_TIMEOUT_SECONDS=14400 -PASSWORD_BCRYPT_ROUNDS=12 -EMAIL_REVERIFY_DAYS=90 - -SMTP_HOST=smtp.gmail.com -SMTP_PORT=587 -SMTP_USERNAME= -SMTP_PASSWORD= -SMTP_USE_TLS=true -EMAIL_FROM_ADDRESS= -EMAIL_FROM_NAME=Aislo Support -EMAIL_RETRY_COUNT=3 -EMAIL_RETRY_DELAY_SECONDS=2 -EMAIL_OTP_VALID_MINUTES=5 -EMAIL_NOTIFICATION_ENABLED=true -ADMIN_EMAIL= diff --git a/A00_Common/app_shell.ts b/A00_Common/app_shell.ts index 05902db..a451c39 100644 --- a/A00_Common/app_shell.ts +++ b/A00_Common/app_shell.ts @@ -9,12 +9,7 @@ import { ui_locales, currentLanguageIndex, setLanguage } from "@ui/ui_template_locale"; import { createButton } from "@ui/ui_template_elements"; -import { - THEME_STORAGE_KEY, - LANG_STORAGE_KEY, - AUTH_TOKEN_KEY, - ROUTES, -} from "@config/config_frontend"; +import { THEME_STORAGE_KEY, LANG_STORAGE_KEY, ROUTES } from "@config/config_frontend"; import { navigateTo, isAuthenticated } from "./router"; /** locale 헬퍼: 현재 언어 인덱스로 문구 반환 */ @@ -51,8 +46,13 @@ function onApp_Lang_Toggle_Click(): void { window.dispatchEvent(new HashChangeEvent("hashchange")); } -function onApp_Logout_Click(): void { - localStorage.removeItem(AUTH_TOKEN_KEY); +async function onApp_Logout_Click(): Promise { + const { logout } = await import("../A06_Login/A06_Login_Api_Fetch"); + try { + await logout(); + } catch { + // 세션이 이미 만료된 경우 등은 무시하고 홈으로 이동 + } navigateTo(ROUTES.A01_HOME); } @@ -105,8 +105,22 @@ function buildHeader(): HTMLElement { }); actions.append(langBtn, themeBtn); - if (isAuthenticated() as any) { - actions.append( + // 인증 버튼은 서버 세션 확인(비동기) 후 채운다. + const authSlot = document.createElement("div"); + authSlot.className = "app-actions__auth"; + actions.append(authSlot); + void fillAuthActions(authSlot); + + header.append(brand, nav, actions); + return header; +} + +/** 서버 세션 상태에 따라 로그인/로그아웃 버튼을 채운다. */ +async function fillAuthActions(slot: HTMLElement): Promise { + const loggedIn = await isAuthenticated(); + slot.innerHTML = ""; + if (loggedIn) { + slot.append( createButton({ label: L("Nav_MyAccount"), variant: "ghost", @@ -119,7 +133,7 @@ function buildHeader(): HTMLElement { }), ); } else { - actions.append( + slot.append( createButton({ label: L("Nav_Login"), variant: "ghost", @@ -132,9 +146,6 @@ function buildHeader(): HTMLElement { }), ); } - - header.append(brand, nav, actions); - return header; } function buildFooter(): HTMLElement { diff --git a/A00_Common/router.ts b/A00_Common/router.ts index 86e5d76..788635e 100644 --- a/A00_Common/router.ts +++ b/A00_Common/router.ts @@ -101,6 +101,37 @@ export async function renderCurrentRoute(outlet: HTMLElement): Promise { return; } + const workflowRoutes: readonly RoutePath[] = [ + ROUTES.B02_PROJ_REGISTER, + ROUTES.B03_FILE_INPUT, + ROUTES.B04_WF1_SURFACE, + ROUTES.B05_WF2_ROUTE, + ROUTES.B06_WF3_PROFILE_CROSS, + ROUTES.B07_WF4_DESIGN_DETAIL, + ROUTES.B08_WF5_QUANTITY, + ROUTES.B09_WF6_ESTIMATION, + ROUTES.B10_PAYMENT, + ROUTES.B11_STATUS, + ]; + + if (workflowRoutes.includes(route)) { + const { fetchSessionUser } = await import("../A06_Login/A06_Login_Api_Fetch"); + try { + const user = await fetchSessionUser(); + if (!user) { + navigateTo(ROUTES.A06_LOGIN); + return; + } + if (!user.company_id) { + navigateTo(ROUTES.B01_ACCOUNT); + return; + } + } catch { + navigateTo(ROUTES.A06_LOGIN); + return; + } + } + const loader = routeTable[route]; if (!loader) { renderPlaceholder(outlet, route); diff --git a/A01_Home/A01_Home_UI_Page.ts b/A01_Home/A01_Home_UI_Page.ts index d163526..110f0c4 100644 --- a/A01_Home/A01_Home_UI_Page.ts +++ b/A01_Home/A01_Home_UI_Page.ts @@ -12,7 +12,7 @@ import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, createCard, createTag } from "@ui/ui_template_elements"; import { navigateTo } from "../A00_Common/router"; -import { ROUTES, AUTH_TOKEN_KEY } from "@config/config_frontend"; +import { ROUTES } from "@config/config_frontend"; import "./A01_Home_UI_Style.css"; /** locale 헬퍼 */ @@ -46,11 +46,6 @@ function onA01_Home_Explore_Click(): void { navigateTo(ROUTES.A02_PROG_DETAIL); } -function navigateToDevPage(route: string): void { - localStorage.setItem(AUTH_TOKEN_KEY, "dev-temp-token"); - navigateTo(route as never); -} - /* ----------------------------------------------------------------------------- * 섹션 빌더 * -------------------------------------------------------------------------- */ @@ -150,56 +145,12 @@ function buildFeatures(): HTMLElement { return section; } -function buildDevMenu(): HTMLElement { - const section = document.createElement("section"); - section.className = "a01-section a01-dev-menu"; - section.style.opacity = "0.6"; - section.style.borderTop = "2px dashed var(--color-slate)"; - section.style.marginTop = "var(--spacing-48)"; - section.style.paddingTop = "var(--spacing-48)"; - - const heading = document.createElement("h2"); - heading.className = "a01-section__title"; - heading.textContent = "🔧 개발 모드 - B 그룹 테스트"; - - const grid = document.createElement("div"); - grid.className = "a01-feature-grid"; - grid.style.gridTemplateColumns = "repeat(auto-fit, minmax(200px, 1fr))"; - - const bPages: [string, string][] = [ - [ROUTES.B01_ACCOUNT, "B01 · 계정 상세"], - [ROUTES.B02_PROJ_REGISTER, "B02 · 프로젝트 등록"], - [ROUTES.B03_FILE_INPUT, "B03 · 파일 입력"], - [ROUTES.B04_WF1_SURFACE, "B04 · 지표면 분석"], - [ROUTES.B05_WF2_ROUTE, "B05 · 경로 설계"], - [ROUTES.B06_WF3_PROFILE_CROSS, "B06 · 종횡단 생성"], - [ROUTES.B07_WF4_DESIGN_DETAIL, "B07 · 상세 설계"], - [ROUTES.B08_WF5_QUANTITY, "B08 · 수량 산출"], - [ROUTES.B09_WF6_ESTIMATION, "B09 · 견적/문서"], - [ROUTES.B10_PAYMENT, "B10 · 결재"], - [ROUTES.B11_STATUS, "B11 · 상태 출력"], - ]; - - for (const [route, label] of bPages) { - const btn = createButton({ - label, - variant: "ghost", - onClick: () => navigateToDevPage(route), - }); - btn.style.width = "100%"; - grid.append(btn); - } - - section.append(heading, grid); - return section; -} - /* ----------------------------------------------------------------------------- * 페이지 진입점 * -------------------------------------------------------------------------- */ export function renderA01Home(root: HTMLElement): void { const page = document.createElement("div"); page.className = "a01-home"; - page.append(buildHero(), buildNews(), buildFeatures(), buildDevMenu()); + page.append(buildHero(), buildNews(), buildFeatures()); root.append(page); } diff --git a/A06_Login/A06_Login_Api_Fetch.ts b/A06_Login/A06_Login_Api_Fetch.ts index 1f77678..ac768d3 100644 --- a/A06_Login/A06_Login_Api_Fetch.ts +++ b/A06_Login/A06_Login_Api_Fetch.ts @@ -11,6 +11,8 @@ export interface SessionUser { email: string; name?: string; role?: string; + company_id?: number | null; + is_master?: boolean; } async function post(path: string, body?: object): Promise { diff --git a/A06_Login/A06_Login_Router.py b/A06_Login/A06_Login_Router.py index 3fc1880..2d3327d 100644 --- a/A06_Login/A06_Login_Router.py +++ b/A06_Login/A06_Login_Router.py @@ -66,7 +66,7 @@ async def request_login(payload: LoginRequest, request: Request): subject, html = security_alert_email(email, failures) send_email_background(ADMIN_EMAIL, subject, html) raise HTTPException(status_code=401, detail="이메일 또는 비밀번호가 올바르지 않습니다.") - if user["status"] != "ACTIVE": + if user["status"] not in ("ACTIVE", "NO_COMPANY"): await record_login(user["id"], email, "FAILURE", "ACCOUNT_INACTIVE", agent) raise HTTPException(status_code=403, detail="활성화되지 않은 계정입니다.") @@ -140,6 +140,8 @@ async def current_session(session: dict = Depends(verify_session)): "email": session["email"], "name": session["name"], "role": session["role"], + "company_id": session["company_id"], + "is_master": session["is_master"], }, } diff --git a/A06_Login/A06_Login_UI_Page.ts b/A06_Login/A06_Login_UI_Page.ts deleted file mode 100644 index 2930c84..0000000 --- a/A06_Login/A06_Login_UI_Page.ts +++ /dev/null @@ -1,106 +0,0 @@ -/* ============================================================================= - * A06_Login_UI_Page.ts - * 로그인 전 06: 로그인 (이메일 + 비밀번호 폼) - * - * 제약 준수: - * - 모든 문구는 ui_template_locale.ts 참조 (하드코딩 금지). - * - 색상/간격은 A06_Login_UI_Style.css + theme.css 변수만 사용. - * - 공통 컴포넌트(createButton/createInputField/createCard) 우선 사용. - * - 이벤트 핸들러는 on[페이지]_[기능]_[액션] 명명. - * - 백엔드 전송 전 1차 유효성 검사 (frontend.md §4). - * ========================================================================== */ - -import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; -import { createButton, createInputField } from "@ui/ui_template_elements"; -import { isBlank, isValidEmail } from "@util/common_util_validate"; -import { navigateTo } from "../A00_Common/router"; -import { ROUTES, AUTH_TOKEN_KEY } from "@config/config_frontend"; -import "./A06_Login_UI_Style.css"; - -/** locale 헬퍼 */ -function L(key: keyof typeof ui_locales): string { - return ui_locales[key][currentLanguageIndex]; -} - -/* ----------------------------------------------------------------------------- - * 페이지 진입점 - * -------------------------------------------------------------------------- */ -export function renderA06Login(root: HTMLElement): void { - const page = document.createElement("div"); - page.className = "a06-login"; - - const card = document.createElement("div"); - card.className = "a06-login__card"; - - const title = document.createElement("h1"); - title.className = "a06-login__title"; - title.textContent = L("A06_Login_Title"); - - const subtitle = document.createElement("p"); - subtitle.className = "a06-login__subtitle"; - subtitle.textContent = L("A06_Login_Subtitle"); - - // 입력 필드 - const emailField = createInputField({ - label: L("A06_Login_Field_Email"), - placeholder: L("A06_Login_Field_Email_Placeholder"), - type: "email", - required: true, - }); - const passwordField = createInputField({ - label: L("A06_Login_Field_Password"), - placeholder: L("A06_Login_Field_Password_Placeholder"), - type: "password", - required: true, - }); - - const form = document.createElement("form"); - form.className = "a06-login__form"; - form.noValidate = true; - - const submitBtn = createButton({ - label: L("A06_Login_Submit"), - variant: "filled", - type: "submit", - }); - submitBtn.classList.add("a06-login__submit"); - - /* 이벤트 핸들러: 제출 시 1차 유효성 검사 */ - function onA06_Login_Submit_Click(event: Event): void { - event.preventDefault(); - emailField.setError(); - passwordField.setError(); - - const email = emailField.input.value; - const password = passwordField.input.value; - - if (isBlank(email) || isBlank(password)) { - if (isBlank(email)) emailField.setError(L("A06_Login_Error_Required")); - if (isBlank(password)) passwordField.setError(L("A06_Login_Error_Required")); - return; - } - if (!isValidEmail(email)) { - emailField.setError(L("A06_Login_Error_Email")); - return; - } - - // TODO: POST /api/a06/login 연동. 성공 시 토큰 저장 후 계정 페이지로. - localStorage.setItem(AUTH_TOKEN_KEY, "dev-placeholder-token"); - navigateTo(ROUTES.B01_ACCOUNT); - } - - form.addEventListener("submit", onA06_Login_Submit_Click); - form.append(emailField.root, passwordField.root, submitBtn); - - // 회원가입 링크 - const toRegister = createButton({ - label: L("A06_Login_ToRegister"), - variant: "ghost", - onClick: () => navigateTo(ROUTES.A07_REGISTER), - }); - toRegister.classList.add("a06-login__link"); - - card.append(title, subtitle, form, toRegister); - page.append(card); - root.append(page); -} diff --git a/A07_Register/A07_Register_Api_Fetch.ts b/A07_Register/A07_Register_Api_Fetch.ts index 5d33597..0eb4a03 100644 --- a/A07_Register/A07_Register_Api_Fetch.ts +++ b/A07_Register/A07_Register_Api_Fetch.ts @@ -3,10 +3,10 @@ import { API_BASE_URL } from "@config/config_frontend"; export interface RegisterPayload { email: string; password: string; + password_confirm: string; name: string; - account_type: "MASTER" | "MEMBER"; - company_id: number | null; - company_name: string | null; + position: string | null; + phone: string | null; terms_version: string; terms_agreed: boolean; privacy_agreed: boolean; diff --git a/A07_Register/A07_Register_Router.py b/A07_Register/A07_Register_Router.py index 267f11f..fd3c456 100644 --- a/A07_Register/A07_Register_Router.py +++ b/A07_Register/A07_Register_Router.py @@ -6,17 +6,17 @@ from fastapi import APIRouter, HTTPException, Query from common_util.common_util_auth import generate_otp, hash_password, verify_password from common_util.common_util_auth_repository import ( - company_master_email, complete_registration, consume_otp, create_registration, get_active_otp, get_user_by_email, + refresh_pending_registration, replace_otp, search_companies, ) from common_util.common_util_email import send_email_background -from common_util.common_util_email_templates import join_request_email, otp_email +from common_util.common_util_email_templates import otp_email from .A07_Register_Schema import RegisterRequest, RegisterVerifyRequest @@ -31,11 +31,21 @@ async def find_companies(q: str = Query(min_length=1, max_length=100)): @router.post("/register/request", status_code=202) async def request_registration(payload: RegisterRequest): email = str(payload.email).lower() - if await get_user_by_email(email): - raise HTTPException(status_code=409, detail="이미 등록된 이메일입니다.") - data = payload.model_dump() + data = payload.model_dump(exclude={"password_confirm"}) data["email"] = email - user_id = await create_registration(data, hash_password(payload.password)) + password_hash = hash_password(payload.password) + + existing = await get_user_by_email(email) + if existing: + # 이미 이메일 인증까지 마친 계정이면 재가입 불가 + if existing["status"] != "PENDING_EMAIL": + raise HTTPException(status_code=409, detail="이미 등록된 이메일입니다.") + # 미인증 계정이면 최신 요청으로 갱신하고 새 인증 코드를 발급 + user_id = existing["id"] + await refresh_pending_registration(user_id, data, password_hash) + else: + user_id = await create_registration(data, password_hash) + code = generate_otp() await replace_otp(user_id, "REGISTER", hash_password(code)) subject, html = otp_email(code, "회원가입") @@ -57,13 +67,9 @@ async def verify_registration(payload: RegisterVerifyRequest): raise HTTPException(status_code=400, detail="인증 코드가 유효하지 않습니다.") await consume_otp(otp["id"]) await complete_registration(user["id"], bool(user["is_master"])) - - if not user["is_master"]: - master_email = await company_master_email(user["company_id"]) - if master_email: - subject, html = join_request_email(user["name"], user["email"]) - send_email_background(master_email, subject, html) + # 회원가입 시점에는 회사가 없으므로(NO_COMPANY) 마스터 알림은 발송하지 않는다. + # 회사 생성/연결은 로그인 후 B01_AccountDetail에서 진행한다. return { "status": "success", - "account_status": "ACTIVE" if user["is_master"] else "PENDING", + "account_status": "NO_COMPANY", } diff --git a/A07_Register/A07_Register_Schema.py b/A07_Register/A07_Register_Schema.py index 431b4c7..e368087 100644 --- a/A07_Register/A07_Register_Schema.py +++ b/A07_Register/A07_Register_Schema.py @@ -1,34 +1,26 @@ """회원가입 요청 스키마.""" -from typing import Literal - from pydantic import BaseModel, EmailStr, Field, model_validator class RegisterRequest(BaseModel): email: EmailStr password: str = Field(min_length=8, max_length=128) + password_confirm: str = Field(min_length=8, max_length=128) name: str = Field(min_length=1, max_length=255) - account_type: Literal["MASTER", "MEMBER"] - company_id: int | None = Field(default=None, ge=1) - company_name: str | None = Field(default=None, max_length=255) - business_number: str | None = Field(default=None, max_length=20) - phone_number: str | None = Field(default=None, max_length=20) - address: str | None = Field(default=None, max_length=255) - representative_name: str | None = Field(default=None, max_length=100) + position: str | None = Field(default=None, max_length=100) + phone: str | None = Field(default=None, max_length=20) terms_version: str = Field(min_length=1, max_length=20) terms_agreed: bool privacy_agreed: bool marketing_agreed: bool = False @model_validator(mode="after") - def validate_account_type(self): + def validate_registration(self): if not self.terms_agreed or not self.privacy_agreed: raise ValueError("필수 약관 동의가 필요합니다.") - if self.account_type == "MASTER" and not self.company_name: - raise ValueError("새 회사명은 필수입니다.") - if self.account_type == "MEMBER" and self.company_id is None: - raise ValueError("참여할 회사를 선택해야 합니다.") + if self.password != self.password_confirm: + raise ValueError("비밀번호와 확인 비밀번호가 일치하지 않습니다.") return self diff --git a/A07_Register/A07_Register_UI_Auth_Page.ts b/A07_Register/A07_Register_UI_Auth_Page.ts index fe917b8..6ff7b15 100644 --- a/A07_Register/A07_Register_UI_Auth_Page.ts +++ b/A07_Register/A07_Register_UI_Auth_Page.ts @@ -14,13 +14,55 @@ import "./A07_Register_UI_Style.css"; const L = (key: keyof typeof ui_locales): string => ui_locales[key][currentLanguageIndex]; -function check(label: string): { root: HTMLLabelElement; input: HTMLInputElement } { - const root = document.createElement("label"); - root.className = "a07-register__check"; +interface AgreementField { + root: HTMLElement; + input: HTMLInputElement; +} + +function checkboxWithAccordion(label: string, contentText: string): AgreementField { + const container = document.createElement("div"); + container.className = "a07-register__agreement"; + + const summary = document.createElement("div"); + summary.className = "a07-register__agreement-summary"; + const input = document.createElement("input"); input.type = "checkbox"; - root.append(input, document.createTextNode(label)); - return { root, input }; + + const labelSpan = document.createElement("span"); + labelSpan.className = "a07-register__agreement-label"; + labelSpan.textContent = label; + + summary.append(input, labelSpan); + + const details = document.createElement("details"); + details.className = "a07-register__agreement-details"; + + const detailsContent = document.createElement("div"); + detailsContent.className = "a07-register__agreement-content"; + detailsContent.textContent = contentText; + + details.appendChild(detailsContent); + + summary.addEventListener("click", (e) => { + if (e.target !== input) { + details.open = !details.open; + } + }); + + container.append(summary, details); + + return { root: container, input }; +} + +async function loadAgreementText(filename: string): Promise { + const filePath = `resources/legal/${filename}`; + const response = await fetch(filePath); + if (!response.ok) { + console.warn(`Failed to load ${filename}`); + return `[${filename}을 불러올 수 없습니다]`; + } + return await response.text(); } export function renderA07Register(root: HTMLElement): void { @@ -31,31 +73,75 @@ export function renderA07Register(root: HTMLElement): void { const title = document.createElement("h1"); title.className = "a07-register__title"; title.textContent = L("A07_Register_Title"); + const name = createInputField({ label: L("A07_Register_Field_Name"), type: "text" }); - const email = createInputField({ label: L("A07_Register_Field_Email"), type: "email" }); - const password = createInputField({ label: L("A07_Register_Field_Password"), type: "password" }); - const company = createInputField({ label: L("A07_Register_Field_Company"), type: "text" }); - const companyId = createInputField({ label: L("A07_Register_Field_CompanyId"), type: "number" }); - companyId.root.hidden = true; - const accountType = document.createElement("select"); - accountType.className = "ui-input"; - accountType.append( - new Option(L("A07_Register_Type_Master"), "MASTER"), - new Option(L("A07_Register_Type_Member"), "MEMBER"), - ); - accountType.addEventListener("change", () => { - company.root.hidden = accountType.value === "MEMBER"; - companyId.root.hidden = accountType.value !== "MEMBER"; + const position = createInputField({ + label: L("A07_Register_Field_Position"), + type: "text", }); - const terms = check(L("A07_Register_Terms")); - const privacy = check(L("A07_Register_Privacy")); - const marketing = check(L("A07_Register_Marketing")); + const email = createInputField({ label: L("A07_Register_Field_Email"), type: "email" }); + const phone = createInputField({ label: L("A07_Register_Field_Phone"), type: "tel" }); + const password = createInputField({ label: L("A07_Register_Field_Password"), type: "password" }); + const passwordConfirm = createInputField({ + label: L("A07_Register_Field_PasswordConfirm"), + type: "password", + }); + + const terms: AgreementField = { + root: document.createElement("div"), + input: document.createElement("input"), + }; + const privacy: AgreementField = { + root: document.createElement("div"), + input: document.createElement("input"), + }; + const marketing: AgreementField = { + root: document.createElement("div"), + input: document.createElement("input"), + }; + const otp = createInputField({ label: L("A07_Register_Field_Otp"), type: "text" }); otp.root.hidden = true; const submit = createButton({ label: L("A07_Register_Submit"), type: "submit" }); const form = document.createElement("form"); form.className = "a07-register__form"; let verifyStep = false; + let termsText = ""; + let privacyText = ""; + let marketingText = ""; + + async function loadAgreementTexts(): Promise { + termsText = await loadAgreementText("terms_of_service.txt"); + privacyText = await loadAgreementText("privacy_policy.txt"); + marketingText = await loadAgreementText("marketing_consent.txt"); + + const termsField = checkboxWithAccordion(L("A07_Register_Terms"), termsText); + terms.root = termsField.root; + terms.input = termsField.input; + + const privacyField = checkboxWithAccordion(L("A07_Register_Privacy"), privacyText); + privacy.root = privacyField.root; + privacy.input = privacyField.input; + + const marketingField = checkboxWithAccordion(L("A07_Register_Marketing"), marketingText); + marketing.root = marketingField.root; + marketing.input = marketingField.input; + + form.innerHTML = ""; + form.append( + name.root, + position.root, + email.root, + phone.root, + password.root, + passwordConfirm.root, + terms.root, + privacy.root, + marketing.root, + otp.root, + submit, + ); + } async function onA07_Register_Submit_Click(event: Event): Promise { event.preventDefault(); @@ -68,28 +154,34 @@ export function renderA07Register(root: HTMLElement): void { return; } if ( - [name, email, password].some((field) => isBlank(field.input.value)) || + [name, email, password, passwordConfirm].some((field) => isBlank(field.input.value)) || !isValidEmail(email.input.value) ) { throw new Error(L("A07_Register_Error_Required")); } if (!hasMinLength(password.input.value, 8)) throw new Error(L("A07_Register_Error_Password")); + if (password.input.value !== passwordConfirm.input.value) + throw new Error(L("A07_Register_Error_PasswordMismatch")); if (!terms.input.checked || !privacy.input.checked) throw new Error(L("A07_Register_Error_Terms")); await requestRegistration({ email: email.input.value.trim(), password: password.input.value, + password_confirm: passwordConfirm.input.value, name: name.input.value.trim(), - account_type: accountType.value as "MASTER" | "MEMBER", - company_id: accountType.value === "MEMBER" ? Number(companyId.input.value) : null, - company_name: accountType.value === "MASTER" ? company.input.value.trim() : null, + position: position.input.value.trim() || null, + phone: phone.input.value.trim() || null, terms_version: "1.0", terms_agreed: true, privacy_agreed: true, marketing_agreed: marketing.input.checked, }); verifyStep = true; - Array.from(form.children).forEach((child) => ((child as HTMLElement).hidden = true)); + Array.from(form.children).forEach((child) => { + if (child !== otp.root && child !== submit) { + (child as HTMLElement).hidden = true; + } + }); otp.root.hidden = false; submit.hidden = false; submit.querySelector(".ui-btn__label")!.textContent = L("A07_Register_Verify"); @@ -102,25 +194,14 @@ export function renderA07Register(root: HTMLElement): void { } form.addEventListener("submit", onA07_Register_Submit_Click); - form.append( - accountType, - company.root, - companyId.root, - name.root, - email.root, - password.root, - terms.root, - privacy.root, - marketing.root, - otp.root, - submit, - ); - const login = createButton({ - label: L("A07_Register_ToLogin"), - variant: "ghost", - onClick: () => navigateTo(ROUTES.A06_LOGIN), + loadAgreementTexts().then(() => { + const login = createButton({ + label: L("A07_Register_ToLogin"), + variant: "ghost", + onClick: () => navigateTo(ROUTES.A06_LOGIN), + }); + card.append(title, form, login); + page.append(card); + root.append(page); }); - card.append(title, form, login); - page.append(card); - root.append(page); } diff --git a/A07_Register/A07_Register_UI_Style.css b/A07_Register/A07_Register_UI_Style.css index 8b23223..baa1c21 100644 --- a/A07_Register/A07_Register_UI_Style.css +++ b/A07_Register/A07_Register_UI_Style.css @@ -56,3 +56,47 @@ color: var(--color-text); font-size: var(--text-body-sm); } + +.a07-register__agreement { + border: 1px solid var(--color-border); + border-radius: var(--radius-input); + overflow: hidden; +} + +.a07-register__agreement-summary { + display: flex; + align-items: center; + gap: var(--spacing-8); + padding: var(--spacing-12) var(--spacing-16); + background-color: var(--color-canvas); + cursor: pointer; + user-select: none; +} + +.a07-register__agreement-summary input[type="checkbox"] { + flex-shrink: 0; +} + +.a07-register__agreement-label { + flex: 1; + font-size: var(--text-body-sm); + color: var(--color-text); +} + +.a07-register__agreement-details { + border-top: 1px solid var(--color-border); +} + +.a07-register__agreement-details[open] { + padding: var(--spacing-12) var(--spacing-16); +} + +.a07-register__agreement-content { + max-height: 200px; + overflow-y: auto; + white-space: pre-wrap; + word-break: break-word; + font-size: var(--text-body-sm); + color: var(--color-slate); + line-height: var(--leading-relaxed); +} diff --git a/B03_FileInput/B03_FileInput_Api_Fetch.ts b/B03_FileInput/B03_FileInput_Api_Fetch.ts index 1c7a20e..a49e29f 100644 --- a/B03_FileInput/B03_FileInput_Api_Fetch.ts +++ b/B03_FileInput/B03_FileInput_Api_Fetch.ts @@ -1,6 +1,6 @@ /* B03 다중 파일 업로드 API 클라이언트 */ -import { API_BASE_URL, API_TIMEOUT_MS, AUTH_TOKEN_KEY } from "@config/config_frontend"; +import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend"; export interface UploadedFileResult { input_file_id: number; @@ -26,11 +26,10 @@ export async function uploadProjectFiles( const controller = new AbortController(); const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS); - const token = localStorage.getItem(AUTH_TOKEN_KEY); try { const response = await fetch(`${API_BASE_URL}/projects/${projectId}/files`, { method: "POST", - headers: token ? { Authorization: `Bearer ${token}` } : undefined, + credentials: "include", body: formData, signal: controller.signal, }); diff --git a/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts b/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts index c0049c4..8aac936 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts @@ -11,7 +11,7 @@ * - 오류 응답 형식 {status:"error", message:"..."}을 Error로 변환. * ========================================================================== */ -import { API_BASE_URL, API_TIMEOUT_MS, AUTH_TOKEN_KEY } from "@config/config_frontend"; +import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend"; /** 지표면 분석 실행 요청 (SurfaceAnalyzeRequest) */ export interface SurfaceAnalyzeRequest { @@ -51,13 +51,12 @@ export interface SurfaceModelListResponse { async function requestJson(path: string, init: RequestInit): Promise { const controller = new AbortController(); const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS); - const token = localStorage.getItem(AUTH_TOKEN_KEY); try { const response = await fetch(`${API_BASE_URL}${path}`, { ...init, + credentials: "include", headers: { "Content-Type": "application/json", - ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(init.headers ?? {}), }, signal: controller.signal, diff --git a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts index d563baa..ab65c69 100644 --- a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts +++ b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts @@ -11,7 +11,7 @@ * - 오류 응답 형식 {status:"error", message:"..."}을 Error로 변환. * ========================================================================== */ -import { API_BASE_URL, API_TIMEOUT_MS, AUTH_TOKEN_KEY } from "@config/config_frontend"; +import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend"; /** 경로 제어점 (BP/EP/CP) */ export interface RoutePoint { @@ -59,13 +59,12 @@ export interface RouteConfirmResponse { async function requestJson(path: string, init: RequestInit): Promise { const controller = new AbortController(); const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS); - const token = localStorage.getItem(AUTH_TOKEN_KEY); try { const response = await fetch(`${API_BASE_URL}${path}`, { ...init, + credentials: "include", headers: { "Content-Type": "application/json", - ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(init.headers ?? {}), }, signal: controller.signal, diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts index 27640aa..a4a1647 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts @@ -12,7 +12,7 @@ * - 오류 응답 형식 {status:"error", message:"..."}을 Error로 변환. * ========================================================================== */ -import { API_BASE_URL, API_TIMEOUT_MS, AUTH_TOKEN_KEY } from "@config/config_frontend"; +import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend"; /** 종횡단 생성 실행 요청 (SectionGenerateRequest) */ export interface SectionGenerateRequest { @@ -58,13 +58,12 @@ export interface SectionConfirmResponse { async function requestJson(path: string, init: RequestInit): Promise { const controller = new AbortController(); const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS); - const token = localStorage.getItem(AUTH_TOKEN_KEY); try { const response = await fetch(`${API_BASE_URL}${path}`, { ...init, + credentials: "include", headers: { "Content-Type": "application/json", - ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(init.headers ?? {}), }, signal: controller.signal, diff --git a/common_util/common_util_auth.py b/common_util/common_util_auth.py index 8fe70ea..2355e5b 100644 --- a/common_util/common_util_auth.py +++ b/common_util/common_util_auth.py @@ -90,7 +90,9 @@ async def verify_session(request: Request) -> dict[str, Any]: now = datetime.utcnow() expired = now > row[3] or (now - row[4]).total_seconds() > SESSION_IDLE_TIMEOUT_SECONDS - if expired or row[10] != "ACTIVE": + # NO_COMPANY(이메일 인증 완료·회사 미연결)도 로그인 유지 대상. 회사 연결 강제는 + # require_company 의존성에서 별도 처리한다. + if expired or row[10] not in ("ACTIVE", "NO_COMPANY"): await cursor.execute("DELETE FROM sessions WHERE id = %s", (session_id,)) await connection.commit() raise HTTPException(status_code=401, detail="세션이 만료되었습니다.") @@ -135,3 +137,11 @@ async def require_system_admin( if session["role"] != "SYSTEM_ADMIN": raise HTTPException(status_code=403, detail="시스템 관리자 권한이 필요합니다.") return session + + +async def require_company( + session: dict[str, Any] = Depends(verify_session), +) -> dict[str, Any]: + if session["company_id"] is None: + raise HTTPException(status_code=403, detail="회사 연결이 필요합니다.") + return session diff --git a/common_util/common_util_auth_repository.py b/common_util/common_util_auth_repository.py index 1ffab82..d2e48e0 100644 --- a/common_util/common_util_auth_repository.py +++ b/common_util/common_util_auth_repository.py @@ -34,46 +34,17 @@ async def create_registration(data: dict[str, Any], password_hash: str) -> int: await connection.begin() await cursor.execute( """INSERT INTO users - (email, password_hash, name, role, is_master, status) - VALUES (%s, %s, %s, %s, %s, 'PENDING_EMAIL')""", + (email, password_hash, name, position, phone, role, is_master, status) + VALUES (%s, %s, %s, %s, %s, 'MEMBER', FALSE, 'PENDING_EMAIL')""", ( data["email"], password_hash, data["name"], - data["account_type"], - data["account_type"] == "MASTER", + data.get("position"), + data.get("phone"), ), ) user_id = cursor.lastrowid - if data["account_type"] == "MASTER": - await cursor.execute( - """INSERT INTO companies - (name, business_registration_number, business_address, created_by, - company_code, phone_number, representative_name, master_user_id) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s)""", - ( - data["company_name"], - data.get("business_number"), - data.get("address"), - user_id, - _company_code(), - data.get("phone_number"), - data.get("representative_name"), - user_id, - ), - ) - company_id = cursor.lastrowid - await cursor.execute( - "UPDATE users SET company_id = %s WHERE id = %s", (company_id, user_id) - ) - else: - await cursor.execute( - "INSERT INTO join_requests (user_id, company_id) VALUES (%s, %s)", - (user_id, data["company_id"]), - ) - await cursor.execute( - "UPDATE users SET company_id = %s WHERE id = %s", (data["company_id"], user_id) - ) await cursor.execute( """INSERT INTO user_consents (user_id, terms_version, terms_agreed, privacy_agreed, marketing_agreed) @@ -93,6 +64,52 @@ async def create_registration(data: dict[str, Any], password_hash: str) -> int: raise +async def refresh_pending_registration( + user_id: int, data: dict[str, Any], password_hash: str +) -> None: + """미인증(PENDING_EMAIL) 계정의 정보를 최신 가입 요청으로 갱신한다. + + 같은 이메일로 재가입을 시도하면 기존 미인증 계정을 덮어써서 + 새 비밀번호·이름·약관 동의로 갱신하고 새 OTP를 받을 수 있게 한다. + """ + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + try: + await connection.begin() + await cursor.execute( + """UPDATE users + SET password_hash = %s, name = %s, position = %s, phone = %s + WHERE id = %s AND status = 'PENDING_EMAIL'""", + ( + password_hash, + data["name"], + data.get("position"), + data.get("phone"), + user_id, + ), + ) + await cursor.execute( + """INSERT INTO user_consents + (user_id, terms_version, terms_agreed, privacy_agreed, marketing_agreed) + VALUES (%s, %s, %s, %s, %s) + ON DUPLICATE KEY UPDATE + terms_agreed = VALUES(terms_agreed), + privacy_agreed = VALUES(privacy_agreed), + marketing_agreed = VALUES(marketing_agreed)""", + ( + user_id, + data["terms_version"], + data["terms_agreed"], + data["privacy_agreed"], + data["marketing_agreed"], + ), + ) + await connection.commit() + except Exception: + await connection.rollback() + raise + + async def replace_otp(user_id: int, purpose: str, otp_hash: str) -> None: expires_at = datetime.utcnow() + timedelta(minutes=OTP_VALID_MINUTES) pool = get_db_pool() @@ -132,13 +149,12 @@ async def consume_otp(otp_id: int) -> None: async def complete_registration(user_id: int, is_master: bool) -> None: - status = "ACTIVE" if is_master else "PENDING" pool = get_db_pool() async with pool.acquire() as connection, connection.cursor() as cursor: await cursor.execute( - """UPDATE users SET status = %s, last_email_verified_at = CURRENT_TIMESTAMP + """UPDATE users SET status = 'NO_COMPANY', last_email_verified_at = CURRENT_TIMESTAMP WHERE id = %s""", - (status, user_id), + (user_id,), ) await connection.commit() diff --git a/config/config_db.py b/config/config_db.py index b12ecfa..1fad1da 100644 --- a/config/config_db.py +++ b/config/config_db.py @@ -34,7 +34,10 @@ async def init_db_pool() -> aiomysql.Pool: password=DB_PASSWORD, minsize=DB_POOL_MIN, maxsize=DB_POOL_MAX, - autocommit=False, + # autocommit=False면 SELECT만 하고 반납된 커넥션에 열린 트랜잭션(옛 스냅샷)이 + # 남아, 다른 커넥션이 커밋한 데이터(예: 방금 생성된 세션)를 못 읽는다. + # 다중 문장 트랜잭션은 connection.begin()을 명시적으로 사용한다. + autocommit=True, charset="utf8mb4", init_command="SET time_zone='+00:00'", ) diff --git a/config/config_frontend.ts b/config/config_frontend.ts index e3c2bb7..9dd148c 100644 --- a/config/config_frontend.ts +++ b/config/config_frontend.ts @@ -19,9 +19,6 @@ export const API_TIMEOUT_MS = 30_000; /** B03~B09 워크플로우에서 사용할 현재 프로젝트 UUID 저장 키 */ export const CURRENT_PROJECT_ID_KEY = "frd_current_project_id"; -/** 인증 토큰 저장 키 (HttpOnly 쿠키 사용, localStorage는 참조용) */ -export const AUTH_TOKEN_KEY = "frd_auth_token"; - /* ----------------------------------------------------------------------------- * 2. 파일 업로드 제한 (B03_FileInput) * -------------------------------------------------------------------------- */ diff --git a/db_management/003_register_split.sql b/db_management/003_register_split.sql new file mode 100644 index 0000000..ec779b6 --- /dev/null +++ b/db_management/003_register_split.sql @@ -0,0 +1,22 @@ +-- ============================================================================= +-- DB 마이그레이션: 회원가입 플로우 분리 (계정 생성과 회사 연결 분리) +-- 파일: 003_register_split.sql +-- +-- 변경 사항: +-- 1. users.status ENUM에 'NO_COMPANY' 추가 (이메일 인증 완료, 회사 미연결 상태) +-- 2. 회원가입 시 company_id=NULL, role='MEMBER', is_master=FALSE로 시작 +-- 3. 이메일 인증 후 회사 생성/연결 페이지에서 진행 +-- ============================================================================= + +USE aislo_db; + +-- 1. users.status ENUM 확장 ('NO_COMPANY' 추가) +ALTER TABLE users +MODIFY status ENUM('PENDING_EMAIL', 'NO_COMPANY', 'PENDING', 'ACTIVE', 'INACTIVE', 'REJECTED') +NOT NULL DEFAULT 'PENDING_EMAIL'; + +-- 2. 기존 데이터 마이그레이션: 활성 사용자의 상태 유지, 새 상태는 이메일 인증 후 자동 설정됨 +-- (기존 PENDING_EMAIL 상태의 사용자는 그대로, ACTIVE/PENDING/INACTIVE/REJECTED는 변경 없음) + +-- 완료 메시지 +-- 마이그레이션 완료: 이제 회원가입 시 회사 정보를 받지 않으며, 로그인 후 B01_AccountDetail에서 회사 생성/연결 diff --git a/main.py b/main.py index d48ee3f..ff19aaf 100644 --- a/main.py +++ b/main.py @@ -28,7 +28,7 @@ from B03_FileInput.B03_FileInput_Router import router as b03_file_input_router from B04_wf1_Surface.B04_wf1_Surface_Router import router as b04_surface_router from B05_wf2_Route.B05_wf2_Route_Router import router as b05_route_router from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Router import router as b06_section_router -from common_util.common_util_auth import verify_session +from common_util.common_util_auth import require_company, verify_session from config.config_db import close_db_pool, get_db_pool, init_db_pool # 설정 import @@ -234,10 +234,11 @@ app.include_router(a07_register_router) app.include_router(a08_support_router) app.include_router(a09_security_router) protected = [Depends(verify_session)] -app.include_router(b03_file_input_router, dependencies=protected) -app.include_router(b04_surface_router, dependencies=protected) -app.include_router(b05_route_router, dependencies=protected) -app.include_router(b06_section_router, dependencies=protected) +protected_with_company = [Depends(verify_session), Depends(require_company)] +app.include_router(b03_file_input_router, dependencies=protected_with_company) +app.include_router(b04_surface_router, dependencies=protected_with_company) +app.include_router(b05_route_router, dependencies=protected_with_company) +app.include_router(b06_section_router, dependencies=protected_with_company) # ───────────────────────────────────────────────────────────────────────── # 앱 실행 diff --git a/resources/legal/marketing_consent.txt b/resources/legal/marketing_consent.txt new file mode 100644 index 0000000..eb4b9f8 --- /dev/null +++ b/resources/legal/marketing_consent.txt @@ -0,0 +1,20 @@ +마케팅 정보 수신 동의 + +Aislo는 다음과 같은 마케팅 정보를 제공하고 있습니다. 동의하지 않아도 기본 서비스 이용에는 지장이 없습니다. + +마케팅 정보 종류: +- 신제품 및 신기능 출시 소식 +- 서비스 업데이트 및 개선 사항 +- 세미나, 웨비나 등 교육 프로그램 안내 +- 특별 할인 또는 프로모션 정보 +- 산업 뉴스레터 및 기술 자료 + +제공 수단: +- 이메일 +- SMS(선택 시) +- 푸시 알림(앱 설치 후 선택 시) + +철회 방법: +이용자는 언제든지 설정 메뉴에서 마케팅 수신을 거부할 수 있습니다. 거부 시 즉시 마케팅 정보 발송이 중단됩니다. + +본 동의는 별도 작성이 없으면 매년 갱신됩니다. diff --git a/resources/legal/privacy_policy.txt b/resources/legal/privacy_policy.txt new file mode 100644 index 0000000..0da8dda --- /dev/null +++ b/resources/legal/privacy_policy.txt @@ -0,0 +1,36 @@ +개인정보처리방침 + +제1조 개인정보의 수집 및 이용 +회사는 다음과 같은 개인정보를 수집하여 서비스 제공, 결제, 고객 지원 등을 위해 이용합니다. + +수집하는 정보: +- 필수: 이메일, 비밀번호(암호화), 이름, 전화번호, 회사명, 사업자등록번호 +- 선택: 주소, 부서명, 직급 +- 자동수집: User-Agent(브라우저/OS 정보), 세션 ID, 활동 로그 + +수집하지 않는 정보: +- IP 주소 +- 위치 정보 +- 결제 카드 정보(결제 시스템에서 별도 처리) + +제2조 개인정보의 보관 +1. 회사는 이용자의 개인정보를 암호화하여 보관합니다. +2. 비밀번호는 bcrypt를 사용하여 단방향 해싱되며, 회사도 원본을 열람할 수 없습니다. +3. 세션은 Secure HttpOnly 쿠키로 보호되며 12시간 또는 4시간 비활동 후 만료됩니다. + +제3조 개인정보의 공개 및 제3자 제공 +회사는 법적 요청(법원 명령, 수사기관 요청)을 제외하고 이용자의 동의 없이 개인정보를 제3자에게 제공하지 않습니다. + +제4조 개인정보의 파기 +1. 이용자가 서비스를 탈퇴할 경우, 개인정보는 소프트 삭제(논리적 삭제) 처리됩니다. +2. 법적 보관 기한 경과 후 물리적 삭제됩니다. + +제5조 보안 +1. 회사는 개인정보를 보호하기 위해 합리적인 노력을 기울입니다. +2. 단, 완벽한 보안을 보장할 수 없으므로, 계정 보호는 이용자의 책임입니다. + +제6조 이용자의 권리 +1. 이용자는 언제든지 자신의 개인정보 열람을 요청할 수 있습니다. +2. 이용자는 자신의 정보 수정 또는 삭제를 요청할 수 있습니다. + +본 방침은 2026년 1월 1일부터 적용됩니다. diff --git a/resources/legal/terms_of_service.txt b/resources/legal/terms_of_service.txt new file mode 100644 index 0000000..1e71864 --- /dev/null +++ b/resources/legal/terms_of_service.txt @@ -0,0 +1,33 @@ +Aislo 이용약관 + +제1조 목적 +본 약관은 (주)임도기술(이하 "회사")이 제공하는 임도 설계 및 견적 자동화 서비스(이하 "서비스")의 이용과 관련하여 회사와 이용자의 권리, 의무 및 책임사항을 규정함을 목적으로 합니다. + +제2조 정의 +1. "회사"란 서비스를 제공하는 법인 또는 개인사업자를 의미합니다. +2. "이용자"란 본 약관에 동의하여 회사가 제공하는 서비스를 이용하는 개인 또는 법인을 의미합니다. +3. "계정"이란 이용자가 회사에 제공한 정보로 생성된 고유의 식별자를 의미합니다. +4. "서비스"란 회사가 제공하는 임도 설계, 경로 최적화, 견적 산출 등 모든 기능을 포함합니다. + +제3조 약관의 효력 및 변경 +1. 본 약관은 이용자가 동의 버튼을 클릭한 시점에 효력이 발생합니다. +2. 회사는 필요시 약관을 변경할 수 있으며, 변경된 약관은 공지 후 적용됩니다. + +제4조 이용자의 의무 +1. 이용자는 타인의 계정을 무단으로 공유하거나 대여할 수 없습니다. +2. 이용자는 서비스를 방해하거나 불법적으로 이용할 수 없습니다. +3. 이용자는 서비스 이용 중 얻은 정보를 상업적 목적으로 재사용할 수 없습니다. + +제5조 서비스 제공 및 중단 +1. 회사는 24시간 이내 사전 통지 없이 서비스를 중단할 수 있습니다. +2. 긴급 보안 상황에서는 통지 없이 즉시 중단할 수 있습니다. + +제6조 책임의 한계 +1. 회사는 이용자의 계정 보관 실패로 인한 손해에 대해 책임을 지지 않습니다. +2. 회사는 이용자가 서비스로부터 얻은 데이터의 정확성을 보장하지 않습니다. + +제7조 기타 +1. 본 약관의 일부가 무효이거나 위법인 경우, 그 부분을 제외한 나머지는 유효합니다. +2. 이용자와 회사 간의 분쟁은 대한민국 법률에 따라 해결됩니다. + +본 약관은 2026년 1월 1일부터 적용됩니다. diff --git a/scratch_inspect.py b/scratch_inspect.py new file mode 100644 index 0000000..51292cf --- /dev/null +++ b/scratch_inspect.py @@ -0,0 +1,31 @@ +import asyncio +import sys +import aiomysql +from dotenv import load_dotenv +load_dotenv(".env") + +from config.config_db import init_db_pool, close_db_pool + +async def main(): + await init_db_pool() + from config.config_db import get_db_pool + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: + try: + await cursor.execute("SELECT * FROM users") + users = await cursor.fetchall() + print("Users count:", len(users)) + for u in users: + print(u) + + await cursor.execute("SELECT * FROM companies") + companies = await cursor.fetchall() + print("Companies count:", len(companies)) + for c in companies: + print(c) + except Exception as e: + print("Error:", e) + await close_db_pool() + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts index 1a6189a..b58a320 100644 --- a/ui_template/ui_template_locale.ts +++ b/ui_template/ui_template_locale.ts @@ -302,6 +302,15 @@ export const ui_locales = { A07_Register_Field_Email_Placeholder: ["이메일을 입력하세요", "Enter your email"], A07_Register_Field_Password: ["비밀번호", "Password"], A07_Register_Field_Password_Placeholder: ["8자 이상 입력하세요", "At least 8 characters"], + A07_Register_Field_PasswordConfirm: ["비밀번호 확인", "Confirm password"], + A07_Register_Field_PasswordConfirm_Placeholder: [ + "비밀번호를 다시 입력하세요", + "Re-enter password", + ], + A07_Register_Field_Position: ["직급", "Position"], + A07_Register_Field_Position_Placeholder: ["예) 과장, 대리, 사원", "e.g., Manager, Deputy, Staff"], + A07_Register_Field_Phone: ["전화번호", "Phone number"], + A07_Register_Field_Phone_Placeholder: ["010-1234-5678", "010-1234-5678"], A07_Register_Submit: ["회원가입", "Sign up"], A07_Register_ToLogin: ["이미 계정이 있으신가요? 로그인", "Already have an account? Sign in"], A07_Register_Error_Required: [ @@ -313,6 +322,10 @@ export const ui_locales = { "비밀번호는 8자 이상이어야 합니다.", "Password must be at least 8 characters.", ], + A07_Register_Error_PasswordMismatch: [ + "비밀번호와 확인 비밀번호가 일치하지 않습니다.", + "Passwords do not match.", + ], A07_Register_Field_AccountType: ["가입 유형", "Account type"], A07_Register_Type_Master: ["새 회사 등록", "Register a new company"], A07_Register_Type_Member: ["기존 회사 참여", "Join an existing company"],