260706_2_로그인 인증 검토
This commit is contained in:
@@ -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` 내용이 펼쳐지는 `<details>/<summary>` 또는 토글 버튼 구조.
|
||||||
|
- 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 전환 확인
|
||||||
@@ -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. 약관 체크박스 옆 라벨 클릭 → `<details>` 펼쳐서 내용 표시 확인
|
||||||
|
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은 별도 작업
|
||||||
@@ -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=
|
|
||||||
+25
-14
@@ -9,12 +9,7 @@
|
|||||||
|
|
||||||
import { ui_locales, currentLanguageIndex, setLanguage } from "@ui/ui_template_locale";
|
import { ui_locales, currentLanguageIndex, setLanguage } from "@ui/ui_template_locale";
|
||||||
import { createButton } from "@ui/ui_template_elements";
|
import { createButton } from "@ui/ui_template_elements";
|
||||||
import {
|
import { THEME_STORAGE_KEY, LANG_STORAGE_KEY, ROUTES } from "@config/config_frontend";
|
||||||
THEME_STORAGE_KEY,
|
|
||||||
LANG_STORAGE_KEY,
|
|
||||||
AUTH_TOKEN_KEY,
|
|
||||||
ROUTES,
|
|
||||||
} from "@config/config_frontend";
|
|
||||||
import { navigateTo, isAuthenticated } from "./router";
|
import { navigateTo, isAuthenticated } from "./router";
|
||||||
|
|
||||||
/** locale 헬퍼: 현재 언어 인덱스로 문구 반환 */
|
/** locale 헬퍼: 현재 언어 인덱스로 문구 반환 */
|
||||||
@@ -51,8 +46,13 @@ function onApp_Lang_Toggle_Click(): void {
|
|||||||
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
||||||
}
|
}
|
||||||
|
|
||||||
function onApp_Logout_Click(): void {
|
async function onApp_Logout_Click(): Promise<void> {
|
||||||
localStorage.removeItem(AUTH_TOKEN_KEY);
|
const { logout } = await import("../A06_Login/A06_Login_Api_Fetch");
|
||||||
|
try {
|
||||||
|
await logout();
|
||||||
|
} catch {
|
||||||
|
// 세션이 이미 만료된 경우 등은 무시하고 홈으로 이동
|
||||||
|
}
|
||||||
navigateTo(ROUTES.A01_HOME);
|
navigateTo(ROUTES.A01_HOME);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,8 +105,22 @@ function buildHeader(): HTMLElement {
|
|||||||
});
|
});
|
||||||
actions.append(langBtn, themeBtn);
|
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<void> {
|
||||||
|
const loggedIn = await isAuthenticated();
|
||||||
|
slot.innerHTML = "";
|
||||||
|
if (loggedIn) {
|
||||||
|
slot.append(
|
||||||
createButton({
|
createButton({
|
||||||
label: L("Nav_MyAccount"),
|
label: L("Nav_MyAccount"),
|
||||||
variant: "ghost",
|
variant: "ghost",
|
||||||
@@ -119,7 +133,7 @@ function buildHeader(): HTMLElement {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
actions.append(
|
slot.append(
|
||||||
createButton({
|
createButton({
|
||||||
label: L("Nav_Login"),
|
label: L("Nav_Login"),
|
||||||
variant: "ghost",
|
variant: "ghost",
|
||||||
@@ -132,9 +146,6 @@ function buildHeader(): HTMLElement {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
header.append(brand, nav, actions);
|
|
||||||
return header;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildFooter(): HTMLElement {
|
function buildFooter(): HTMLElement {
|
||||||
|
|||||||
@@ -101,6 +101,37 @@ export async function renderCurrentRoute(outlet: HTMLElement): Promise<void> {
|
|||||||
return;
|
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];
|
const loader = routeTable[route];
|
||||||
if (!loader) {
|
if (!loader) {
|
||||||
renderPlaceholder(outlet, route);
|
renderPlaceholder(outlet, route);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
||||||
import { createButton, createCard, createTag } from "@ui/ui_template_elements";
|
import { createButton, createCard, createTag } from "@ui/ui_template_elements";
|
||||||
import { navigateTo } from "../A00_Common/router";
|
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";
|
import "./A01_Home_UI_Style.css";
|
||||||
|
|
||||||
/** locale 헬퍼 */
|
/** locale 헬퍼 */
|
||||||
@@ -46,11 +46,6 @@ function onA01_Home_Explore_Click(): void {
|
|||||||
navigateTo(ROUTES.A02_PROG_DETAIL);
|
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;
|
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 {
|
export function renderA01Home(root: HTMLElement): void {
|
||||||
const page = document.createElement("div");
|
const page = document.createElement("div");
|
||||||
page.className = "a01-home";
|
page.className = "a01-home";
|
||||||
page.append(buildHero(), buildNews(), buildFeatures(), buildDevMenu());
|
page.append(buildHero(), buildNews(), buildFeatures());
|
||||||
root.append(page);
|
root.append(page);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export interface SessionUser {
|
|||||||
email: string;
|
email: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
role?: string;
|
role?: string;
|
||||||
|
company_id?: number | null;
|
||||||
|
is_master?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function post(path: string, body?: object): Promise<ApiResult> {
|
async function post(path: string, body?: object): Promise<ApiResult> {
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ async def request_login(payload: LoginRequest, request: Request):
|
|||||||
subject, html = security_alert_email(email, failures)
|
subject, html = security_alert_email(email, failures)
|
||||||
send_email_background(ADMIN_EMAIL, subject, html)
|
send_email_background(ADMIN_EMAIL, subject, html)
|
||||||
raise HTTPException(status_code=401, detail="이메일 또는 비밀번호가 올바르지 않습니다.")
|
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)
|
await record_login(user["id"], email, "FAILURE", "ACCOUNT_INACTIVE", agent)
|
||||||
raise HTTPException(status_code=403, detail="활성화되지 않은 계정입니다.")
|
raise HTTPException(status_code=403, detail="활성화되지 않은 계정입니다.")
|
||||||
|
|
||||||
@@ -140,6 +140,8 @@ async def current_session(session: dict = Depends(verify_session)):
|
|||||||
"email": session["email"],
|
"email": session["email"],
|
||||||
"name": session["name"],
|
"name": session["name"],
|
||||||
"role": session["role"],
|
"role": session["role"],
|
||||||
|
"company_id": session["company_id"],
|
||||||
|
"is_master": session["is_master"],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -3,10 +3,10 @@ import { API_BASE_URL } from "@config/config_frontend";
|
|||||||
export interface RegisterPayload {
|
export interface RegisterPayload {
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
password_confirm: string;
|
||||||
name: string;
|
name: string;
|
||||||
account_type: "MASTER" | "MEMBER";
|
position: string | null;
|
||||||
company_id: number | null;
|
phone: string | null;
|
||||||
company_name: string | null;
|
|
||||||
terms_version: string;
|
terms_version: string;
|
||||||
terms_agreed: boolean;
|
terms_agreed: boolean;
|
||||||
privacy_agreed: boolean;
|
privacy_agreed: boolean;
|
||||||
|
|||||||
@@ -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 import generate_otp, hash_password, verify_password
|
||||||
from common_util.common_util_auth_repository import (
|
from common_util.common_util_auth_repository import (
|
||||||
company_master_email,
|
|
||||||
complete_registration,
|
complete_registration,
|
||||||
consume_otp,
|
consume_otp,
|
||||||
create_registration,
|
create_registration,
|
||||||
get_active_otp,
|
get_active_otp,
|
||||||
get_user_by_email,
|
get_user_by_email,
|
||||||
|
refresh_pending_registration,
|
||||||
replace_otp,
|
replace_otp,
|
||||||
search_companies,
|
search_companies,
|
||||||
)
|
)
|
||||||
from common_util.common_util_email import send_email_background
|
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
|
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)
|
@router.post("/register/request", status_code=202)
|
||||||
async def request_registration(payload: RegisterRequest):
|
async def request_registration(payload: RegisterRequest):
|
||||||
email = str(payload.email).lower()
|
email = str(payload.email).lower()
|
||||||
if await get_user_by_email(email):
|
data = payload.model_dump(exclude={"password_confirm"})
|
||||||
raise HTTPException(status_code=409, detail="이미 등록된 이메일입니다.")
|
|
||||||
data = payload.model_dump()
|
|
||||||
data["email"] = email
|
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()
|
code = generate_otp()
|
||||||
await replace_otp(user_id, "REGISTER", hash_password(code))
|
await replace_otp(user_id, "REGISTER", hash_password(code))
|
||||||
subject, html = otp_email(code, "회원가입")
|
subject, html = otp_email(code, "회원가입")
|
||||||
@@ -57,13 +67,9 @@ async def verify_registration(payload: RegisterVerifyRequest):
|
|||||||
raise HTTPException(status_code=400, detail="인증 코드가 유효하지 않습니다.")
|
raise HTTPException(status_code=400, detail="인증 코드가 유효하지 않습니다.")
|
||||||
await consume_otp(otp["id"])
|
await consume_otp(otp["id"])
|
||||||
await complete_registration(user["id"], bool(user["is_master"]))
|
await complete_registration(user["id"], bool(user["is_master"]))
|
||||||
|
# 회원가입 시점에는 회사가 없으므로(NO_COMPANY) 마스터 알림은 발송하지 않는다.
|
||||||
if not user["is_master"]:
|
# 회사 생성/연결은 로그인 후 B01_AccountDetail에서 진행한다.
|
||||||
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)
|
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"account_status": "ACTIVE" if user["is_master"] else "PENDING",
|
"account_status": "NO_COMPANY",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +1,26 @@
|
|||||||
"""회원가입 요청 스키마."""
|
"""회원가입 요청 스키마."""
|
||||||
|
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
from pydantic import BaseModel, EmailStr, Field, model_validator
|
from pydantic import BaseModel, EmailStr, Field, model_validator
|
||||||
|
|
||||||
|
|
||||||
class RegisterRequest(BaseModel):
|
class RegisterRequest(BaseModel):
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
password: str = Field(min_length=8, max_length=128)
|
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)
|
name: str = Field(min_length=1, max_length=255)
|
||||||
account_type: Literal["MASTER", "MEMBER"]
|
position: str | None = Field(default=None, max_length=100)
|
||||||
company_id: int | None = Field(default=None, ge=1)
|
phone: str | None = Field(default=None, max_length=20)
|
||||||
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)
|
|
||||||
terms_version: str = Field(min_length=1, max_length=20)
|
terms_version: str = Field(min_length=1, max_length=20)
|
||||||
terms_agreed: bool
|
terms_agreed: bool
|
||||||
privacy_agreed: bool
|
privacy_agreed: bool
|
||||||
marketing_agreed: bool = False
|
marketing_agreed: bool = False
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def validate_account_type(self):
|
def validate_registration(self):
|
||||||
if not self.terms_agreed or not self.privacy_agreed:
|
if not self.terms_agreed or not self.privacy_agreed:
|
||||||
raise ValueError("필수 약관 동의가 필요합니다.")
|
raise ValueError("필수 약관 동의가 필요합니다.")
|
||||||
if self.account_type == "MASTER" and not self.company_name:
|
if self.password != self.password_confirm:
|
||||||
raise ValueError("새 회사명은 필수입니다.")
|
raise ValueError("비밀번호와 확인 비밀번호가 일치하지 않습니다.")
|
||||||
if self.account_type == "MEMBER" and self.company_id is None:
|
|
||||||
raise ValueError("참여할 회사를 선택해야 합니다.")
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,13 +14,55 @@ import "./A07_Register_UI_Style.css";
|
|||||||
|
|
||||||
const L = (key: keyof typeof ui_locales): string => ui_locales[key][currentLanguageIndex];
|
const L = (key: keyof typeof ui_locales): string => ui_locales[key][currentLanguageIndex];
|
||||||
|
|
||||||
function check(label: string): { root: HTMLLabelElement; input: HTMLInputElement } {
|
interface AgreementField {
|
||||||
const root = document.createElement("label");
|
root: HTMLElement;
|
||||||
root.className = "a07-register__check";
|
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");
|
const input = document.createElement("input");
|
||||||
input.type = "checkbox";
|
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<string> {
|
||||||
|
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 {
|
export function renderA07Register(root: HTMLElement): void {
|
||||||
@@ -31,31 +73,75 @@ export function renderA07Register(root: HTMLElement): void {
|
|||||||
const title = document.createElement("h1");
|
const title = document.createElement("h1");
|
||||||
title.className = "a07-register__title";
|
title.className = "a07-register__title";
|
||||||
title.textContent = L("A07_Register_Title");
|
title.textContent = L("A07_Register_Title");
|
||||||
|
|
||||||
const name = createInputField({ label: L("A07_Register_Field_Name"), type: "text" });
|
const name = createInputField({ label: L("A07_Register_Field_Name"), type: "text" });
|
||||||
const email = createInputField({ label: L("A07_Register_Field_Email"), type: "email" });
|
const position = createInputField({
|
||||||
const password = createInputField({ label: L("A07_Register_Field_Password"), type: "password" });
|
label: L("A07_Register_Field_Position"),
|
||||||
const company = createInputField({ label: L("A07_Register_Field_Company"), type: "text" });
|
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 terms = check(L("A07_Register_Terms"));
|
const email = createInputField({ label: L("A07_Register_Field_Email"), type: "email" });
|
||||||
const privacy = check(L("A07_Register_Privacy"));
|
const phone = createInputField({ label: L("A07_Register_Field_Phone"), type: "tel" });
|
||||||
const marketing = check(L("A07_Register_Marketing"));
|
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" });
|
const otp = createInputField({ label: L("A07_Register_Field_Otp"), type: "text" });
|
||||||
otp.root.hidden = true;
|
otp.root.hidden = true;
|
||||||
const submit = createButton({ label: L("A07_Register_Submit"), type: "submit" });
|
const submit = createButton({ label: L("A07_Register_Submit"), type: "submit" });
|
||||||
const form = document.createElement("form");
|
const form = document.createElement("form");
|
||||||
form.className = "a07-register__form";
|
form.className = "a07-register__form";
|
||||||
let verifyStep = false;
|
let verifyStep = false;
|
||||||
|
let termsText = "";
|
||||||
|
let privacyText = "";
|
||||||
|
let marketingText = "";
|
||||||
|
|
||||||
|
async function loadAgreementTexts(): Promise<void> {
|
||||||
|
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<void> {
|
async function onA07_Register_Submit_Click(event: Event): Promise<void> {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -68,28 +154,34 @@ export function renderA07Register(root: HTMLElement): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
[name, email, password].some((field) => isBlank(field.input.value)) ||
|
[name, email, password, passwordConfirm].some((field) => isBlank(field.input.value)) ||
|
||||||
!isValidEmail(email.input.value)
|
!isValidEmail(email.input.value)
|
||||||
) {
|
) {
|
||||||
throw new Error(L("A07_Register_Error_Required"));
|
throw new Error(L("A07_Register_Error_Required"));
|
||||||
}
|
}
|
||||||
if (!hasMinLength(password.input.value, 8)) throw new Error(L("A07_Register_Error_Password"));
|
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)
|
if (!terms.input.checked || !privacy.input.checked)
|
||||||
throw new Error(L("A07_Register_Error_Terms"));
|
throw new Error(L("A07_Register_Error_Terms"));
|
||||||
await requestRegistration({
|
await requestRegistration({
|
||||||
email: email.input.value.trim(),
|
email: email.input.value.trim(),
|
||||||
password: password.input.value,
|
password: password.input.value,
|
||||||
|
password_confirm: passwordConfirm.input.value,
|
||||||
name: name.input.value.trim(),
|
name: name.input.value.trim(),
|
||||||
account_type: accountType.value as "MASTER" | "MEMBER",
|
position: position.input.value.trim() || null,
|
||||||
company_id: accountType.value === "MEMBER" ? Number(companyId.input.value) : null,
|
phone: phone.input.value.trim() || null,
|
||||||
company_name: accountType.value === "MASTER" ? company.input.value.trim() : null,
|
|
||||||
terms_version: "1.0",
|
terms_version: "1.0",
|
||||||
terms_agreed: true,
|
terms_agreed: true,
|
||||||
privacy_agreed: true,
|
privacy_agreed: true,
|
||||||
marketing_agreed: marketing.input.checked,
|
marketing_agreed: marketing.input.checked,
|
||||||
});
|
});
|
||||||
verifyStep = true;
|
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;
|
otp.root.hidden = false;
|
||||||
submit.hidden = false;
|
submit.hidden = false;
|
||||||
submit.querySelector(".ui-btn__label")!.textContent = L("A07_Register_Verify");
|
submit.querySelector(".ui-btn__label")!.textContent = L("A07_Register_Verify");
|
||||||
@@ -102,19 +194,7 @@ export function renderA07Register(root: HTMLElement): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
form.addEventListener("submit", onA07_Register_Submit_Click);
|
form.addEventListener("submit", onA07_Register_Submit_Click);
|
||||||
form.append(
|
loadAgreementTexts().then(() => {
|
||||||
accountType,
|
|
||||||
company.root,
|
|
||||||
companyId.root,
|
|
||||||
name.root,
|
|
||||||
email.root,
|
|
||||||
password.root,
|
|
||||||
terms.root,
|
|
||||||
privacy.root,
|
|
||||||
marketing.root,
|
|
||||||
otp.root,
|
|
||||||
submit,
|
|
||||||
);
|
|
||||||
const login = createButton({
|
const login = createButton({
|
||||||
label: L("A07_Register_ToLogin"),
|
label: L("A07_Register_ToLogin"),
|
||||||
variant: "ghost",
|
variant: "ghost",
|
||||||
@@ -123,4 +203,5 @@ export function renderA07Register(root: HTMLElement): void {
|
|||||||
card.append(title, form, login);
|
card.append(title, form, login);
|
||||||
page.append(card);
|
page.append(card);
|
||||||
root.append(page);
|
root.append(page);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,3 +56,47 @@
|
|||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
font-size: var(--text-body-sm);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/* B03 다중 파일 업로드 API 클라이언트 */
|
/* 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 {
|
export interface UploadedFileResult {
|
||||||
input_file_id: number;
|
input_file_id: number;
|
||||||
@@ -26,11 +26,10 @@ export async function uploadProjectFiles(
|
|||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
||||||
const token = localStorage.getItem(AUTH_TOKEN_KEY);
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/files`, {
|
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/files`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
credentials: "include",
|
||||||
body: formData,
|
body: formData,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
* - 오류 응답 형식 {status:"error", message:"..."}을 Error로 변환.
|
* - 오류 응답 형식 {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) */
|
/** 지표면 분석 실행 요청 (SurfaceAnalyzeRequest) */
|
||||||
export interface SurfaceAnalyzeRequest {
|
export interface SurfaceAnalyzeRequest {
|
||||||
@@ -51,13 +51,12 @@ export interface SurfaceModelListResponse {
|
|||||||
async function requestJson<T>(path: string, init: RequestInit): Promise<T> {
|
async function requestJson<T>(path: string, init: RequestInit): Promise<T> {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
||||||
const token = localStorage.getItem(AUTH_TOKEN_KEY);
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||||
...init,
|
...init,
|
||||||
|
credentials: "include",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
||||||
...(init.headers ?? {}),
|
...(init.headers ?? {}),
|
||||||
},
|
},
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
* - 오류 응답 형식 {status:"error", message:"..."}을 Error로 변환.
|
* - 오류 응답 형식 {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) */
|
/** 경로 제어점 (BP/EP/CP) */
|
||||||
export interface RoutePoint {
|
export interface RoutePoint {
|
||||||
@@ -59,13 +59,12 @@ export interface RouteConfirmResponse {
|
|||||||
async function requestJson<T>(path: string, init: RequestInit): Promise<T> {
|
async function requestJson<T>(path: string, init: RequestInit): Promise<T> {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
||||||
const token = localStorage.getItem(AUTH_TOKEN_KEY);
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||||
...init,
|
...init,
|
||||||
|
credentials: "include",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
||||||
...(init.headers ?? {}),
|
...(init.headers ?? {}),
|
||||||
},
|
},
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
* - 오류 응답 형식 {status:"error", message:"..."}을 Error로 변환.
|
* - 오류 응답 형식 {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) */
|
/** 종횡단 생성 실행 요청 (SectionGenerateRequest) */
|
||||||
export interface SectionGenerateRequest {
|
export interface SectionGenerateRequest {
|
||||||
@@ -58,13 +58,12 @@ export interface SectionConfirmResponse {
|
|||||||
async function requestJson<T>(path: string, init: RequestInit): Promise<T> {
|
async function requestJson<T>(path: string, init: RequestInit): Promise<T> {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
||||||
const token = localStorage.getItem(AUTH_TOKEN_KEY);
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||||
...init,
|
...init,
|
||||||
|
credentials: "include",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
||||||
...(init.headers ?? {}),
|
...(init.headers ?? {}),
|
||||||
},
|
},
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
|
|||||||
@@ -90,7 +90,9 @@ async def verify_session(request: Request) -> dict[str, Any]:
|
|||||||
|
|
||||||
now = datetime.utcnow()
|
now = datetime.utcnow()
|
||||||
expired = now > row[3] or (now - row[4]).total_seconds() > SESSION_IDLE_TIMEOUT_SECONDS
|
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 cursor.execute("DELETE FROM sessions WHERE id = %s", (session_id,))
|
||||||
await connection.commit()
|
await connection.commit()
|
||||||
raise HTTPException(status_code=401, detail="세션이 만료되었습니다.")
|
raise HTTPException(status_code=401, detail="세션이 만료되었습니다.")
|
||||||
@@ -135,3 +137,11 @@ async def require_system_admin(
|
|||||||
if session["role"] != "SYSTEM_ADMIN":
|
if session["role"] != "SYSTEM_ADMIN":
|
||||||
raise HTTPException(status_code=403, detail="시스템 관리자 권한이 필요합니다.")
|
raise HTTPException(status_code=403, detail="시스템 관리자 권한이 필요합니다.")
|
||||||
return session
|
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
|
||||||
|
|||||||
@@ -34,46 +34,17 @@ async def create_registration(data: dict[str, Any], password_hash: str) -> int:
|
|||||||
await connection.begin()
|
await connection.begin()
|
||||||
await cursor.execute(
|
await cursor.execute(
|
||||||
"""INSERT INTO users
|
"""INSERT INTO users
|
||||||
(email, password_hash, name, role, is_master, status)
|
(email, password_hash, name, position, phone, role, is_master, status)
|
||||||
VALUES (%s, %s, %s, %s, %s, 'PENDING_EMAIL')""",
|
VALUES (%s, %s, %s, %s, %s, 'MEMBER', FALSE, 'PENDING_EMAIL')""",
|
||||||
(
|
(
|
||||||
data["email"],
|
data["email"],
|
||||||
password_hash,
|
password_hash,
|
||||||
data["name"],
|
data["name"],
|
||||||
data["account_type"],
|
data.get("position"),
|
||||||
data["account_type"] == "MASTER",
|
data.get("phone"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
user_id = cursor.lastrowid
|
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(
|
await cursor.execute(
|
||||||
"""INSERT INTO user_consents
|
"""INSERT INTO user_consents
|
||||||
(user_id, terms_version, terms_agreed, privacy_agreed, marketing_agreed)
|
(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
|
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:
|
async def replace_otp(user_id: int, purpose: str, otp_hash: str) -> None:
|
||||||
expires_at = datetime.utcnow() + timedelta(minutes=OTP_VALID_MINUTES)
|
expires_at = datetime.utcnow() + timedelta(minutes=OTP_VALID_MINUTES)
|
||||||
pool = get_db_pool()
|
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:
|
async def complete_registration(user_id: int, is_master: bool) -> None:
|
||||||
status = "ACTIVE" if is_master else "PENDING"
|
|
||||||
pool = get_db_pool()
|
pool = get_db_pool()
|
||||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||||
await cursor.execute(
|
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""",
|
WHERE id = %s""",
|
||||||
(status, user_id),
|
(user_id,),
|
||||||
)
|
)
|
||||||
await connection.commit()
|
await connection.commit()
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -34,7 +34,10 @@ async def init_db_pool() -> aiomysql.Pool:
|
|||||||
password=DB_PASSWORD,
|
password=DB_PASSWORD,
|
||||||
minsize=DB_POOL_MIN,
|
minsize=DB_POOL_MIN,
|
||||||
maxsize=DB_POOL_MAX,
|
maxsize=DB_POOL_MAX,
|
||||||
autocommit=False,
|
# autocommit=False면 SELECT만 하고 반납된 커넥션에 열린 트랜잭션(옛 스냅샷)이
|
||||||
|
# 남아, 다른 커넥션이 커밋한 데이터(예: 방금 생성된 세션)를 못 읽는다.
|
||||||
|
# 다중 문장 트랜잭션은 connection.begin()을 명시적으로 사용한다.
|
||||||
|
autocommit=True,
|
||||||
charset="utf8mb4",
|
charset="utf8mb4",
|
||||||
init_command="SET time_zone='+00:00'",
|
init_command="SET time_zone='+00:00'",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -19,9 +19,6 @@ export const API_TIMEOUT_MS = 30_000;
|
|||||||
/** B03~B09 워크플로우에서 사용할 현재 프로젝트 UUID 저장 키 */
|
/** B03~B09 워크플로우에서 사용할 현재 프로젝트 UUID 저장 키 */
|
||||||
export const CURRENT_PROJECT_ID_KEY = "frd_current_project_id";
|
export const CURRENT_PROJECT_ID_KEY = "frd_current_project_id";
|
||||||
|
|
||||||
/** 인증 토큰 저장 키 (HttpOnly 쿠키 사용, localStorage는 참조용) */
|
|
||||||
export const AUTH_TOKEN_KEY = "frd_auth_token";
|
|
||||||
|
|
||||||
/* -----------------------------------------------------------------------------
|
/* -----------------------------------------------------------------------------
|
||||||
* 2. 파일 업로드 제한 (B03_FileInput)
|
* 2. 파일 업로드 제한 (B03_FileInput)
|
||||||
* -------------------------------------------------------------------------- */
|
* -------------------------------------------------------------------------- */
|
||||||
|
|||||||
@@ -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에서 회사 생성/연결
|
||||||
@@ -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 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 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 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
|
from config.config_db import close_db_pool, get_db_pool, init_db_pool
|
||||||
|
|
||||||
# 설정 import
|
# 설정 import
|
||||||
@@ -234,10 +234,11 @@ app.include_router(a07_register_router)
|
|||||||
app.include_router(a08_support_router)
|
app.include_router(a08_support_router)
|
||||||
app.include_router(a09_security_router)
|
app.include_router(a09_security_router)
|
||||||
protected = [Depends(verify_session)]
|
protected = [Depends(verify_session)]
|
||||||
app.include_router(b03_file_input_router, dependencies=protected)
|
protected_with_company = [Depends(verify_session), Depends(require_company)]
|
||||||
app.include_router(b04_surface_router, dependencies=protected)
|
app.include_router(b03_file_input_router, dependencies=protected_with_company)
|
||||||
app.include_router(b05_route_router, dependencies=protected)
|
app.include_router(b04_surface_router, dependencies=protected_with_company)
|
||||||
app.include_router(b06_section_router, dependencies=protected)
|
app.include_router(b05_route_router, dependencies=protected_with_company)
|
||||||
|
app.include_router(b06_section_router, dependencies=protected_with_company)
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────
|
||||||
# 앱 실행
|
# 앱 실행
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
마케팅 정보 수신 동의
|
||||||
|
|
||||||
|
Aislo는 다음과 같은 마케팅 정보를 제공하고 있습니다. 동의하지 않아도 기본 서비스 이용에는 지장이 없습니다.
|
||||||
|
|
||||||
|
마케팅 정보 종류:
|
||||||
|
- 신제품 및 신기능 출시 소식
|
||||||
|
- 서비스 업데이트 및 개선 사항
|
||||||
|
- 세미나, 웨비나 등 교육 프로그램 안내
|
||||||
|
- 특별 할인 또는 프로모션 정보
|
||||||
|
- 산업 뉴스레터 및 기술 자료
|
||||||
|
|
||||||
|
제공 수단:
|
||||||
|
- 이메일
|
||||||
|
- SMS(선택 시)
|
||||||
|
- 푸시 알림(앱 설치 후 선택 시)
|
||||||
|
|
||||||
|
철회 방법:
|
||||||
|
이용자는 언제든지 설정 메뉴에서 마케팅 수신을 거부할 수 있습니다. 거부 시 즉시 마케팅 정보 발송이 중단됩니다.
|
||||||
|
|
||||||
|
본 동의는 별도 작성이 없으면 매년 갱신됩니다.
|
||||||
@@ -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일부터 적용됩니다.
|
||||||
@@ -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일부터 적용됩니다.
|
||||||
@@ -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())
|
||||||
@@ -302,6 +302,15 @@ export const ui_locales = {
|
|||||||
A07_Register_Field_Email_Placeholder: ["이메일을 입력하세요", "Enter your email"],
|
A07_Register_Field_Email_Placeholder: ["이메일을 입력하세요", "Enter your email"],
|
||||||
A07_Register_Field_Password: ["비밀번호", "Password"],
|
A07_Register_Field_Password: ["비밀번호", "Password"],
|
||||||
A07_Register_Field_Password_Placeholder: ["8자 이상 입력하세요", "At least 8 characters"],
|
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_Submit: ["회원가입", "Sign up"],
|
||||||
A07_Register_ToLogin: ["이미 계정이 있으신가요? 로그인", "Already have an account? Sign in"],
|
A07_Register_ToLogin: ["이미 계정이 있으신가요? 로그인", "Already have an account? Sign in"],
|
||||||
A07_Register_Error_Required: [
|
A07_Register_Error_Required: [
|
||||||
@@ -313,6 +322,10 @@ export const ui_locales = {
|
|||||||
"비밀번호는 8자 이상이어야 합니다.",
|
"비밀번호는 8자 이상이어야 합니다.",
|
||||||
"Password must be at least 8 characters.",
|
"Password must be at least 8 characters.",
|
||||||
],
|
],
|
||||||
|
A07_Register_Error_PasswordMismatch: [
|
||||||
|
"비밀번호와 확인 비밀번호가 일치하지 않습니다.",
|
||||||
|
"Passwords do not match.",
|
||||||
|
],
|
||||||
A07_Register_Field_AccountType: ["가입 유형", "Account type"],
|
A07_Register_Field_AccountType: ["가입 유형", "Account type"],
|
||||||
A07_Register_Type_Master: ["새 회사 등록", "Register a new company"],
|
A07_Register_Type_Master: ["새 회사 등록", "Register a new company"],
|
||||||
A07_Register_Type_Member: ["기존 회사 참여", "Join an existing company"],
|
A07_Register_Type_Member: ["기존 회사 참여", "Join an existing company"],
|
||||||
|
|||||||
Reference in New Issue
Block a user