260706_2_로그인 인증 검토

This commit is contained in:
2026-07-07 20:40:10 +09:00
parent f8633bb1fe
commit 5da0260be3
29 changed files with 720 additions and 336 deletions
+94
View File
@@ -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 전환 확인
+127
View File
@@ -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은 별도 작업
-29
View File
@@ -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
View File
@@ -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<void> {
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<void> {
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 {
+31
View File
@@ -101,6 +101,37 @@ export async function renderCurrentRoute(outlet: HTMLElement): Promise<void> {
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);
+2 -51
View File
@@ -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);
}
+2
View File
@@ -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<ApiResult> {
+3 -1
View File
@@ -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"],
},
}
-106
View File
@@ -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 -3
View File
@@ -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;
+19 -13
View File
@@ -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",
}
+6 -14
View File
@@ -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
+128 -47
View File
@@ -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<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 {
@@ -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<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> {
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);
}
+44
View File
@@ -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);
}
+2 -3
View File
@@ -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,
});
+2 -3
View File
@@ -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<T>(path: string, init: RequestInit): Promise<T> {
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,
+2 -3
View File
@@ -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<T>(path: string, init: RequestInit): Promise<T> {
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,
@@ -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<T>(path: string, init: RequestInit): Promise<T> {
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,
+11 -1
View File
@@ -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
+52 -36
View File
@@ -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()
+4 -1
View File
@@ -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'",
)
-3
View File
@@ -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)
* -------------------------------------------------------------------------- */
+22
View File
@@ -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에서 회사 생성/연결
+6 -5
View File
@@ -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)
# ─────────────────────────────────────────────────────────────────────────
# 앱 실행
+20
View File
@@ -0,0 +1,20 @@
마케팅 정보 수신 동의
Aislo는 다음과 같은 마케팅 정보를 제공하고 있습니다. 동의하지 않아도 기본 서비스 이용에는 지장이 없습니다.
마케팅 정보 종류:
- 신제품 및 신기능 출시 소식
- 서비스 업데이트 및 개선 사항
- 세미나, 웨비나 등 교육 프로그램 안내
- 특별 할인 또는 프로모션 정보
- 산업 뉴스레터 및 기술 자료
제공 수단:
- 이메일
- SMS(선택 시)
- 푸시 알림(앱 설치 후 선택 시)
철회 방법:
이용자는 언제든지 설정 메뉴에서 마케팅 수신을 거부할 수 있습니다. 거부 시 즉시 마케팅 정보 발송이 중단됩니다.
본 동의는 별도 작성이 없으면 매년 갱신됩니다.
+36
View File
@@ -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일부터 적용됩니다.
+33
View File
@@ -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일부터 적용됩니다.
+31
View File
@@ -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())
+13
View File
@@ -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"],