feat(user): 사용자 자기정보 편집과 관리자 재설정 플로우를 연동
- lib/widgets/app_shell.dart에서 내 정보 다이얼로그를 추가하고 UserRepository.updateMe·비밀번호 변경 로직을 연결 - lib/features/masters/user/* 모듈에 phone·forcePasswordChange·passwordUpdatedAt 필드를 반영하고 reset-password/update-me API를 사용 - lib/core/validation/password_rules.dart을 신설해 비밀번호 정책 검증을 공통화하고 신규 위젯·테스트에서 재사용 - doc/stock_approval_system_api_v4.md 등 문서를 users 스펙 개편 내용으로 갱신하고 user_management_plan.md를 추가 - test/widgets/app_shell_test.dart 등에서 자기정보 수정·비밀번호 재설정 시나리오를 검증하고 기존 테스트를 보강
This commit is contained in:
@@ -13,13 +13,14 @@
|
|||||||
| 4 | 보고서 PDF 스트리밍·메타데이터 생성 | ✅ 해결 (`backend/src/api/v1/reports.rs:94`) | `ReportingRepositoryRemote`가 스트림·파일명 메타 처리하는지 합동 점검 |
|
| 4 | 보고서 PDF 스트리밍·메타데이터 생성 | ✅ 해결 (`backend/src/api/v1/reports.rs:94`) | `ReportingRepositoryRemote`가 스트림·파일명 메타 처리하는지 합동 점검 |
|
||||||
| 5 | 그룹-메뉴 권한 `path`·`is_deleted`·`include_deleted` | ✅ 해결 (`backend/src/domain/group_menu_permissions.rs:149`, `backend/src/adapters/repositories/group_menu_permissions.rs:227`) | DTO/필터·권한 편집 UI가 추가 필드로 회귀 없는지 테스트 |
|
| 5 | 그룹-메뉴 권한 `path`·`is_deleted`·`include_deleted` | ✅ 해결 (`backend/src/domain/group_menu_permissions.rs:149`, `backend/src/adapters/repositories/group_menu_permissions.rs:227`) | DTO/필터·권한 편집 UI가 추가 필드로 회귀 없는지 테스트 |
|
||||||
| 6 | 대시보드 KPI `delta` 전일 대비 비율 계산 | ✅ 해결 (`backend/src/adapters/repositories/dashboard.rs:61`) | KPI 카드/차트가 백분율·부호 표시를 지원하는지 확인 |
|
| 6 | 대시보드 KPI `delta` 전일 대비 비율 계산 | ✅ 해결 (`backend/src/adapters/repositories/dashboard.rs:61`) | KPI 카드/차트가 백분율·부호 표시를 지원하는지 확인 |
|
||||||
|
| 7 | 사용자 요약(`created_by`, `requester`) 기본 노출 및 회귀 테스트 | ✅ 해결 (`backend/src/domain/approval_templates.rs:34`, `backend/src/adapters/repositories/approval_templates.rs:100`, `backend/src/adapters/repositories/approvals.rs:878`, `backend/src/adapters/repositories/stock_transactions.rs:1173`, `backend/src/adapters/repositories/reports.rs:256`) | 프런트 DTO가 사번(`employee_id`)·이름을 모두 반영하는지, 리스트/리포트 표시가 정상인지 검증 |
|
||||||
|
|
||||||
아래 섹션에서 영역별 관찰 내용과 프런트엔드 후속 작업을 정리했다.
|
아래 섹션에서 영역별 관찰 내용과 프런트엔드 후속 작업을 정리했다.
|
||||||
|
|
||||||
## 로그인 & 세션
|
## 로그인 & 세션
|
||||||
- 변경 없음: 로그인/세션 API는 기존 계약과 동일하며(`backend/src/api/v1/login.rs`), 프런트 `AuthSessionDto` 매핑도 변동이 없다(`lib/features/auth/data/dtos/auth_session_dto.dart:17`).
|
- 변경 없음: 로그인/세션 API는 기존 계약과 동일하며(`backend/src/api/v1/login.rs`), 프런트 `AuthSessionDto` 매핑도 변동이 없다(`lib/features/auth/data/dtos/auth_session_dto.dart:17`).
|
||||||
- 체크포인트: 세션 만료 401 처리 시 백엔드 토큰 갱신 로직은 유지되므로, 프런트 재시도/로그아웃 UX를 QA 체크리스트에 유지한다.
|
- 체크포인트: 세션 만료 401 처리 시 백엔드 토큰 갱신 로직은 유지되므로, 프런트 재시도/로그아웃 UX를 QA 체크리스트에 유지한다.
|
||||||
- 추가 확인: `POST /api/v1/auth/refresh` 오류 메시지가 문서 규격(`token expired`, `invalid token`)으로 일치하는지 스테이징 로그로 검증한다. 메시지 표준화가 미완료인 경우 `Failure` 매퍼에서 임시 매핑을 추가해야 한다.
|
- 추가 확인: `POST /api/v1/auth/refresh` 오류 메시지가 문서 규격(`token expired`, `token revoked`, `invalid token`)으로 일치하는지 스테이징 로그로 검증한다. 메시지 표준화가 미완료인 경우 `Failure` 매퍼에서 임시 매핑을 추가해야 한다.
|
||||||
|
|
||||||
## 대시보드
|
## 대시보드
|
||||||
- KPI `delta`가 전일 대비 증감률(예: `0.125` → 12.5%)로 채워지며(`backend/src/adapters/repositories/dashboard.rs:61`), 프런트는 % 포맷과 부호를 고려해 렌더링해야 한다(`lib/features/dashboard/presentation/widgets/dashboard_kpi_card.dart`).
|
- KPI `delta`가 전일 대비 증감률(예: `0.125` → 12.5%)로 채워지며(`backend/src/adapters/repositories/dashboard.rs:61`), 프런트는 % 포맷과 부호를 고려해 렌더링해야 한다(`lib/features/dashboard/presentation/widgets/dashboard_kpi_card.dart`).
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
---
|
---
|
||||||
|
|
||||||
## 0. 구현 현황 요약 (2025-09-18 기준)
|
## 0. 구현 현황 요약 (2025-09-18 기준)
|
||||||
- 마스터 데이터: `/vendors`, `/uoms`, `/transaction-types`, `/transaction-statuses`, `/approval-statuses`, `/approval-actions`, `/warehouses`, `/customers`, `/products`, `/employees`, `/groups`, `/menus`, `/group-menu-permissions`, `/zipcodes`
|
- 마스터 데이터: `/vendors`, `/uoms`, `/transaction-types`, `/transaction-statuses`, `/approval-statuses`, `/approval-actions`, `/warehouses`, `/customers`, `/products`, `/users`, `/groups`, `/menus`, `/group-menu-permissions`, `/zipcodes`
|
||||||
- 각 자원은 `/api/v1/<resource>` 패턴을 따르며, 목록 필터·페이지네이션·`include` 확장을 지원한다.
|
- 각 자원은 `/api/v1/<resource>` 패턴을 따르며, 목록 필터·페이지네이션·`include` 확장을 지원한다.
|
||||||
- 그룹 권한은 `/api/v1/group-menu-permissions`와 `/api/v1/groups/{id}/permissions` 일괄 갱신 엔드포인트로 관리한다. `group-menu-permissions` 응답의 `menu` 객체에는 `route_path`와 동일 값을 가진 `path`가 포함되며 각 항목은 `is_deleted`를 노출한다. `include=group,menu` 확장과 `include_deleted=true` 파라미터로 삭제 권한을 함께 조회할 수 있다.
|
- 그룹 권한은 `/api/v1/group-menu-permissions`와 `/api/v1/groups/{id}/permissions` 일괄 갱신 엔드포인트로 관리한다. `group-menu-permissions` 응답의 `menu` 객체에는 `route_path`와 동일 값을 가진 `path`가 포함되며 각 항목은 `is_deleted`를 노출한다. `include=group,menu` 확장과 `include_deleted=true` 파라미터로 삭제 권한을 함께 조회할 수 있다.
|
||||||
- 우편번호 검색 `/api/v1/zipcodes`는 부분 일치 검색(`q`, `zipcode`, `road_name`)과 단건 조회를 제공한다.
|
- 우편번호 검색 `/api/v1/zipcodes`는 부분 일치 검색(`q`, `zipcode`, `road_name`)과 단건 조회를 제공한다.
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
- **증분 조회:** `updated_since=ISO8601`.
|
- **증분 조회:** `updated_since=ISO8601`.
|
||||||
- **정렬:** `sort`(기본 `updated_at`), `order=asc|desc`(기본 desc).
|
- **정렬:** `sort`(기본 `updated_at`), `order=asc|desc`(기본 desc).
|
||||||
- **검색:** `q` 파라미터로 코드/명칭 부분 일치. 필요한 경우 컬럼별 필터 지원.
|
- **검색:** `q` 파라미터로 코드/명칭 부분 일치. 필요한 경우 컬럼별 필터 지원.
|
||||||
- **Include 확장:** `include` 쿼리로 추가 데이터(`lines`, `customers`, `approval`, `steps`, `histories`, `permissions`, `employees` 등) 선택 가능. 포함 대상은 FK 요약 정보를 이미 반환하므로 `include`는 상세 컬렉션을 불러올 때 사용.
|
- **Include 확장:** `include` 쿼리로 추가 데이터(`lines`, `customers`, `approval`, `steps`, `histories`, `permissions`, `users` 등) 선택 가능. 포함 대상은 FK 요약 정보를 이미 반환하므로 `include`는 상세 컬렉션을 불러올 때 사용.
|
||||||
- **배열 입력:** 트랜잭션 라인, 트랜잭션 고객, 결재 단계, 그룹 메뉴 권한 등 다건 작업은 항상 배열(`[]`) 기반으로 요청한다.
|
- **배열 입력:** 트랜잭션 라인, 트랜잭션 고객, 결재 단계, 그룹 메뉴 권한 등 다건 작업은 항상 배열(`[]`) 기반으로 요청한다.
|
||||||
- **Primary Key 규칙:** Create 요청 바디에는 PK를 포함하지 않는다. Create 응답 및 나머지 모든 요청·응답에는 PK가 포함돼야 한다(경로에 이미 포함된 경우라도 바디 내 `id`를 명시).
|
- **Primary Key 규칙:** Create 요청 바디에는 PK를 포함하지 않는다. Create 응답 및 나머지 모든 요청·응답에는 PK가 포함돼야 한다(경로에 이미 포함된 경우라도 바디 내 `id`를 명시).
|
||||||
- **에러 규격:**
|
- **에러 규격:**
|
||||||
@@ -130,7 +130,7 @@
|
|||||||
---
|
---
|
||||||
|
|
||||||
## 3. 마스터 데이터 API
|
## 3. 마스터 데이터 API
|
||||||
리소스: `/vendors`, `/warehouses`, `/customers`, `/employees`, `/products`, `/menus`, `/groups`, `/zipcodes`
|
리소스: `/vendors`, `/warehouses`, `/customers`, `/users`, `/products`, `/menus`, `/groups`, `/zipcodes`
|
||||||
|
|
||||||
> 기본 정렬: 별도 `sort` 파라미터가 없으면 항상 `id` 오름차순으로 응답을 정렬한다. (`order` 기본값도 `asc`)
|
> 기본 정렬: 별도 `sort` 파라미터가 없으면 항상 `id` 오름차순으로 응답을 정렬한다. (`order` 기본값도 `asc`)
|
||||||
|
|
||||||
@@ -245,20 +245,22 @@
|
|||||||
|
|
||||||
> `contact_name`은 고객사 담당자 실명. 선택 입력이며 미입력 시 `null`.
|
> `contact_name`은 고객사 담당자 실명. 선택 입력이며 미입력 시 `null`.
|
||||||
|
|
||||||
`GET /employees?page=1`
|
`GET /users?page=1`
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"id": 7,
|
"id": 7,
|
||||||
"employee_no": "E2025001",
|
"employee_id": "E2025001",
|
||||||
"employee_name": "김승인",
|
"name": "김승인",
|
||||||
"email": "approver@example.com",
|
"email": "approver@example.com",
|
||||||
"mobile_no": "010-2222-1111",
|
"phone": "+82-10-2222-1111",
|
||||||
"group": {
|
"group": {
|
||||||
"id": 2,
|
"id": 2,
|
||||||
"group_name": "창고 관리자"
|
"group_name": "창고 관리자"
|
||||||
},
|
},
|
||||||
|
"force_password_change": false,
|
||||||
|
"password_updated_at": "2025-01-10T09:00:00Z",
|
||||||
"is_active": true,
|
"is_active": true,
|
||||||
"created_at": "2025-01-02T09:00:00Z",
|
"created_at": "2025-01-02T09:00:00Z",
|
||||||
"updated_at": "2025-01-10T11:00:00Z"
|
"updated_at": "2025-01-10T11:00:00Z"
|
||||||
@@ -270,7 +272,7 @@
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`GET /groups?include=permissions,employees`
|
`GET /groups?include=permissions,users`
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"items": [
|
"items": [
|
||||||
@@ -295,11 +297,11 @@
|
|||||||
"can_delete": false
|
"can_delete": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"employees": [
|
"users": [
|
||||||
{
|
{
|
||||||
"id": 7,
|
"id": 7,
|
||||||
"employee_no": "E2025001",
|
"employee_id": "E2025001",
|
||||||
"employee_name": "김승인"
|
"name": "김승인"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"created_at": "2025-01-01T00:00:00Z",
|
"created_at": "2025-01-01T00:00:00Z",
|
||||||
@@ -405,6 +407,107 @@
|
|||||||
|
|
||||||
> `zipcodes`는 대량 데이터 특성상 `GET /zipcodes?zipcode=06000&road_name=세종대로` 형태로 조회하며, 응답 항목에는 `zipcode`, `sido`, `sigungu`, `road_name`, `building_main_no` 등 주소 구성 요소가 포함된다.
|
> `zipcodes`는 대량 데이터 특성상 `GET /zipcodes?zipcode=06000&road_name=세종대로` 형태로 조회하며, 응답 항목에는 `zipcode`, `sido`, `sigungu`, `road_name`, `building_main_no` 등 주소 구성 요소가 포함된다.
|
||||||
|
|
||||||
|
### 3.7 사용자 계정 전용 엔드포인트
|
||||||
|
|
||||||
|
#### 3.7.1 관리자 사용자 생성
|
||||||
|
`POST /users`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"employee_id": "E2025012",
|
||||||
|
"name": "홍관리",
|
||||||
|
"email": "admin@example.com",
|
||||||
|
"phone": "+82-10-3333-4444",
|
||||||
|
"group_id": 1,
|
||||||
|
"password": "Admin!234"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
응답:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": 15,
|
||||||
|
"employee_id": "E2025012",
|
||||||
|
"name": "홍관리",
|
||||||
|
"email": "admin@example.com",
|
||||||
|
"phone": "+82-10-3333-4444",
|
||||||
|
"group": {
|
||||||
|
"id": 1,
|
||||||
|
"group_name": "전사 관리자"
|
||||||
|
},
|
||||||
|
"force_password_change": true,
|
||||||
|
"password_updated_at": "2025-03-10T00:00:00Z",
|
||||||
|
"is_active": true,
|
||||||
|
"created_at": "2025-03-10T00:00:00Z",
|
||||||
|
"updated_at": "2025-03-10T00:00:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- `employee_id`는 영문/숫자 4~32자(`^[A-Za-z0-9]{4,32}$`)이고 대소문자 구분 없이 유니크해야 한다. 저장 시 대문자로 정규화한다.
|
||||||
|
- `password`는 길이 8~24자, 대/소문자·숫자·특수문자 각 1자 이상을 포함해야 하며 평문은 응답에 포함되지 않는다.
|
||||||
|
- 생성 즉시 `force_password_change=true`로 설정하고 임시 비밀번호 안내 메일을 발송 큐에 적재한다.
|
||||||
|
- 최고 관리자 계정 `terabits`는 삭제하거나 비활성화하지 않으며, 비밀번호 재설정만 허용한다.
|
||||||
|
|
||||||
|
#### 3.7.2 자기 정보 수정
|
||||||
|
`PATCH /users/me`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"email": "approver@example.com",
|
||||||
|
"phone": "+82-10-2222-1111",
|
||||||
|
"password": "NewPass!234",
|
||||||
|
"current_password": "TempPass1"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
응답:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": 7,
|
||||||
|
"employee_id": "E2025001",
|
||||||
|
"name": "김승인",
|
||||||
|
"email": "approver@example.com",
|
||||||
|
"phone": "+82-10-2222-1111",
|
||||||
|
"force_password_change": false,
|
||||||
|
"password_updated_at": "2025-03-11T02:00:00Z",
|
||||||
|
"is_active": true,
|
||||||
|
"created_at": "2025-01-02T09:00:00Z",
|
||||||
|
"updated_at": "2025-03-11T02:00:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- 변경 가능한 필드는 `email`, `phone`, `password` 3개다. `password`를 변경할 때는 `current_password` 필드가 필수다.
|
||||||
|
- 비밀번호 변경이 성공하면 서버는 기존 액세스/리프레시 토큰을 모두 폐기하고, 클라이언트는 로그아웃 팝업을 띄운 뒤 재로그인을 요구해야 한다.
|
||||||
|
|
||||||
|
#### 3.7.3 관리자 사용자 수정
|
||||||
|
`PATCH /users/{id}`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"email": "approver@example.com",
|
||||||
|
"phone": "+82-10-2222-1111",
|
||||||
|
"group_id": 2,
|
||||||
|
"is_active": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
응답은 갱신된 사용자 단건을 반환한다. 관리자는 `group_id`, `email`, `phone`, `is_active`, `force_password_change`를 변경할 수 있다(비밀번호 직접 수정 불가).
|
||||||
|
|
||||||
|
#### 3.7.4 관리자 비밀번호 재설정
|
||||||
|
`POST /users/{id}/reset-password`
|
||||||
|
|
||||||
|
요청 바디는 비워둔다. 서버는 8자 영문 대소문자+숫자 조합으로 임시 비밀번호를 생성해 이메일 발송 큐에 넣고, 응답으로 상태를 전달한다.
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": 7,
|
||||||
|
"employee_id": "E2025001",
|
||||||
|
"email": "approver@example.com",
|
||||||
|
"force_password_change": true,
|
||||||
|
"password_updated_at": "2025-03-11T02:05:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- 임시 비밀번호 값은 응답에 포함하지 않는다. 메일 템플릿에서 안내하며, 관리 콘솔에는 마스킹된 값만 노출한다.
|
||||||
|
- 재설정 시 즉시 기존 세션을 무효화하고, 사용자 최초 로그인 시 비밀번호 변경 화면으로 리다이렉션한다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. 트랜잭션 API
|
## 4. 트랜잭션 API
|
||||||
@@ -476,8 +579,8 @@
|
|||||||
"transaction_date": "2025-09-18",
|
"transaction_date": "2025-09-18",
|
||||||
"created_by": {
|
"created_by": {
|
||||||
"id": 7,
|
"id": 7,
|
||||||
"employee_no": "E2025001",
|
"employee_id": "E2025001",
|
||||||
"employee_name": "김승인"
|
"name": "김승인"
|
||||||
},
|
},
|
||||||
"note": "창고 입고",
|
"note": "창고 입고",
|
||||||
"is_active": true,
|
"is_active": true,
|
||||||
@@ -537,7 +640,7 @@
|
|||||||
},
|
},
|
||||||
"approver": {
|
"approver": {
|
||||||
"id": 21,
|
"id": 21,
|
||||||
"employee_no": "E2025002",
|
"employee_id": "E2025002",
|
||||||
"name": "박검토"
|
"name": "박검토"
|
||||||
},
|
},
|
||||||
"assigned_at": "2025-09-18T06:05:00Z",
|
"assigned_at": "2025-09-18T06:05:00Z",
|
||||||
@@ -546,7 +649,7 @@
|
|||||||
},
|
},
|
||||||
"requester": {
|
"requester": {
|
||||||
"id": 7,
|
"id": 7,
|
||||||
"employee_no": "E2025001",
|
"employee_id": "E2025001",
|
||||||
"name": "김승인"
|
"name": "김승인"
|
||||||
},
|
},
|
||||||
"requested_at": "2025-09-18T06:00:00Z",
|
"requested_at": "2025-09-18T06:00:00Z",
|
||||||
@@ -593,8 +696,8 @@
|
|||||||
"transaction_date": "2025-09-18",
|
"transaction_date": "2025-09-18",
|
||||||
"created_by": {
|
"created_by": {
|
||||||
"id": 7,
|
"id": 7,
|
||||||
"employee_no": "E2025001",
|
"employee_id": "E2025001",
|
||||||
"employee_name": "김승인"
|
"name": "김승인"
|
||||||
},
|
},
|
||||||
"note": "창고 입고",
|
"note": "창고 입고",
|
||||||
"is_active": true,
|
"is_active": true,
|
||||||
@@ -649,7 +752,7 @@
|
|||||||
"step_order": 1,
|
"step_order": 1,
|
||||||
"approver": {
|
"approver": {
|
||||||
"id": 21,
|
"id": 21,
|
||||||
"employee_no": "E2025002",
|
"employee_id": "E2025002",
|
||||||
"name": "박검토"
|
"name": "박검토"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
@@ -664,7 +767,7 @@
|
|||||||
},
|
},
|
||||||
"requester": {
|
"requester": {
|
||||||
"id": 7,
|
"id": 7,
|
||||||
"employee_no": "E2025001",
|
"employee_id": "E2025001",
|
||||||
"name": "김승인"
|
"name": "김승인"
|
||||||
},
|
},
|
||||||
"requested_at": "2025-09-18T06:00:00Z",
|
"requested_at": "2025-09-18T06:00:00Z",
|
||||||
@@ -677,7 +780,7 @@
|
|||||||
"step_order": 1,
|
"step_order": 1,
|
||||||
"approver": {
|
"approver": {
|
||||||
"id": 21,
|
"id": 21,
|
||||||
"employee_no": "E2025002",
|
"employee_id": "E2025002",
|
||||||
"name": "박검토"
|
"name": "박검토"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
@@ -707,7 +810,7 @@
|
|||||||
},
|
},
|
||||||
"approver": {
|
"approver": {
|
||||||
"id": 7,
|
"id": 7,
|
||||||
"employee_no": "E2025001",
|
"employee_id": "E2025001",
|
||||||
"name": "김승인"
|
"name": "김승인"
|
||||||
},
|
},
|
||||||
"action_at": "2025-09-18T06:00:00Z",
|
"action_at": "2025-09-18T06:00:00Z",
|
||||||
@@ -882,7 +985,7 @@
|
|||||||
"current_step": null,
|
"current_step": null,
|
||||||
"requester": {
|
"requester": {
|
||||||
"id": 7,
|
"id": 7,
|
||||||
"employee_no": "E2025001",
|
"employee_id": "E2025001",
|
||||||
"name": "김승인"
|
"name": "김승인"
|
||||||
},
|
},
|
||||||
"requested_at": "2025-09-18T06:00:00Z",
|
"requested_at": "2025-09-18T06:00:00Z",
|
||||||
@@ -929,7 +1032,7 @@
|
|||||||
},
|
},
|
||||||
"approver": {
|
"approver": {
|
||||||
"id": 21,
|
"id": 21,
|
||||||
"employee_no": "E2025002",
|
"employee_id": "E2025002",
|
||||||
"name": "박검토"
|
"name": "박검토"
|
||||||
},
|
},
|
||||||
"assigned_at": "2025-09-18T06:05:00Z",
|
"assigned_at": "2025-09-18T06:05:00Z",
|
||||||
@@ -938,7 +1041,7 @@
|
|||||||
},
|
},
|
||||||
"requester": {
|
"requester": {
|
||||||
"id": 7,
|
"id": 7,
|
||||||
"employee_no": "E2025001",
|
"employee_id": "E2025001",
|
||||||
"name": "김승인"
|
"name": "김승인"
|
||||||
},
|
},
|
||||||
"requested_at": "2025-09-18T06:00:00Z",
|
"requested_at": "2025-09-18T06:00:00Z",
|
||||||
@@ -960,7 +1063,7 @@
|
|||||||
},
|
},
|
||||||
"approver": {
|
"approver": {
|
||||||
"id": 21,
|
"id": 21,
|
||||||
"employee_no": "E2025002",
|
"employee_id": "E2025002",
|
||||||
"name": "박검토"
|
"name": "박검토"
|
||||||
},
|
},
|
||||||
"assigned_at": "2025-09-18T06:05:00Z",
|
"assigned_at": "2025-09-18T06:05:00Z",
|
||||||
@@ -984,7 +1087,7 @@
|
|||||||
},
|
},
|
||||||
"approver": {
|
"approver": {
|
||||||
"id": 7,
|
"id": 7,
|
||||||
"employee_no": "E2025001",
|
"employee_id": "E2025001",
|
||||||
"name": "김승인"
|
"name": "김승인"
|
||||||
},
|
},
|
||||||
"action_at": "2025-09-18T06:00:00Z",
|
"action_at": "2025-09-18T06:00:00Z",
|
||||||
@@ -1028,7 +1131,7 @@
|
|||||||
},
|
},
|
||||||
"approver": {
|
"approver": {
|
||||||
"id": 21,
|
"id": 21,
|
||||||
"employee_no": "E2025002",
|
"employee_id": "E2025002",
|
||||||
"name": "박검토"
|
"name": "박검토"
|
||||||
},
|
},
|
||||||
"assigned_at": "2025-09-18T06:05:00Z",
|
"assigned_at": "2025-09-18T06:05:00Z",
|
||||||
@@ -1037,7 +1140,7 @@
|
|||||||
},
|
},
|
||||||
"requester": {
|
"requester": {
|
||||||
"id": 7,
|
"id": 7,
|
||||||
"employee_no": "E2025001",
|
"employee_id": "E2025001",
|
||||||
"name": "김승인"
|
"name": "김승인"
|
||||||
},
|
},
|
||||||
"requested_at": "2025-09-18T06:00:00Z",
|
"requested_at": "2025-09-18T06:00:00Z",
|
||||||
@@ -1055,7 +1158,7 @@
|
|||||||
},
|
},
|
||||||
"approver": {
|
"approver": {
|
||||||
"id": 21,
|
"id": 21,
|
||||||
"employee_no": "E2025002",
|
"employee_id": "E2025002",
|
||||||
"name": "박검토"
|
"name": "박검토"
|
||||||
},
|
},
|
||||||
"assigned_at": "2025-09-18T06:05:00Z",
|
"assigned_at": "2025-09-18T06:05:00Z",
|
||||||
@@ -1079,7 +1182,7 @@
|
|||||||
},
|
},
|
||||||
"approver": {
|
"approver": {
|
||||||
"id": 7,
|
"id": 7,
|
||||||
"employee_no": "E2025001",
|
"employee_id": "E2025001",
|
||||||
"name": "김승인"
|
"name": "김승인"
|
||||||
},
|
},
|
||||||
"action_at": "2025-09-18T06:00:00Z",
|
"action_at": "2025-09-18T06:00:00Z",
|
||||||
@@ -1285,7 +1388,7 @@
|
|||||||
"step_order": 2,
|
"step_order": 2,
|
||||||
"approver": {
|
"approver": {
|
||||||
"id": 34,
|
"id": 34,
|
||||||
"employee_no": "E2025003",
|
"employee_id": "E2025003",
|
||||||
"name": "최검토"
|
"name": "최검토"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
@@ -1345,7 +1448,7 @@
|
|||||||
"step_order": 2,
|
"step_order": 2,
|
||||||
"approver": {
|
"approver": {
|
||||||
"id": 34,
|
"id": 34,
|
||||||
"employee_no": "E2025003",
|
"employee_id": "E2025003",
|
||||||
"name": "최검토"
|
"name": "최검토"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
@@ -1413,7 +1516,7 @@
|
|||||||
},
|
},
|
||||||
"requester": {
|
"requester": {
|
||||||
"id": 7,
|
"id": 7,
|
||||||
"employee_no": "E2025001",
|
"employee_id": "E2025001",
|
||||||
"name": "김승인"
|
"name": "김승인"
|
||||||
},
|
},
|
||||||
"requested_at": "2025-09-18T06:00:00Z",
|
"requested_at": "2025-09-18T06:00:00Z",
|
||||||
@@ -1445,8 +1548,8 @@
|
|||||||
"note": "보류 코멘트",
|
"note": "보류 코멘트",
|
||||||
"approver": {
|
"approver": {
|
||||||
"id": 21,
|
"id": 21,
|
||||||
"employee_no": "E2025002",
|
"employee_id": "E2025002",
|
||||||
"employee_name": "박검토"
|
"name": "박검토"
|
||||||
},
|
},
|
||||||
"from_status": {
|
"from_status": {
|
||||||
"id": 1,
|
"id": 1,
|
||||||
@@ -1476,7 +1579,7 @@
|
|||||||
"step_order": 1,
|
"step_order": 1,
|
||||||
"approver": {
|
"approver": {
|
||||||
"id": 21,
|
"id": 21,
|
||||||
"employee_no": "E2025002",
|
"employee_id": "E2025002",
|
||||||
"name": "박검토"
|
"name": "박검토"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
@@ -1524,8 +1627,8 @@
|
|||||||
"note": "보류 코멘트",
|
"note": "보류 코멘트",
|
||||||
"approver": {
|
"approver": {
|
||||||
"id": 21,
|
"id": 21,
|
||||||
"employee_no": "E2025002",
|
"employee_id": "E2025002",
|
||||||
"employee_name": "박검토"
|
"name": "박검토"
|
||||||
},
|
},
|
||||||
"from_status": null,
|
"from_status": null,
|
||||||
"to_status": {
|
"to_status": {
|
||||||
@@ -1550,8 +1653,8 @@
|
|||||||
"step_order": 1,
|
"step_order": 1,
|
||||||
"approver": {
|
"approver": {
|
||||||
"id": 21,
|
"id": 21,
|
||||||
"employee_no": "E2025002",
|
"employee_id": "E2025002",
|
||||||
"employee_name": "박검토"
|
"name": "박검토"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"id": 2,
|
"id": 2,
|
||||||
@@ -1581,8 +1684,8 @@
|
|||||||
"description": "입고 결재 2단계",
|
"description": "입고 결재 2단계",
|
||||||
"created_by": {
|
"created_by": {
|
||||||
"id": 7,
|
"id": 7,
|
||||||
"employee_no": "E2025001",
|
"employee_id": "E2025001",
|
||||||
"employee_name": "김승인"
|
"name": "김승인"
|
||||||
},
|
},
|
||||||
"is_active": true,
|
"is_active": true,
|
||||||
"created_at": "2025-01-20T00:00:00Z",
|
"created_at": "2025-01-20T00:00:00Z",
|
||||||
@@ -1594,6 +1697,7 @@
|
|||||||
"total": 1
|
"total": 1
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
- `created_by`는 작성자의 `id`, `employee_id`, `name`을 포함하며 `include=` 파라미터 없이도 기본 반환된다.
|
||||||
|
|
||||||
### 6.2 단건 조회
|
### 6.2 단건 조회
|
||||||
`GET /approval-templates/3001?include=steps`
|
`GET /approval-templates/3001?include=steps`
|
||||||
@@ -1606,8 +1710,8 @@
|
|||||||
"description": "입고 결재 2단계",
|
"description": "입고 결재 2단계",
|
||||||
"created_by": {
|
"created_by": {
|
||||||
"id": 7,
|
"id": 7,
|
||||||
"employee_no": "E2025001",
|
"employee_id": "E2025001",
|
||||||
"employee_name": "김승인"
|
"name": "김승인"
|
||||||
},
|
},
|
||||||
"steps": [
|
"steps": [
|
||||||
{
|
{
|
||||||
@@ -1615,8 +1719,8 @@
|
|||||||
"step_order": 1,
|
"step_order": 1,
|
||||||
"approver": {
|
"approver": {
|
||||||
"id": 21,
|
"id": 21,
|
||||||
"employee_no": "E2025002",
|
"employee_id": "E2025002",
|
||||||
"employee_name": "박검토"
|
"name": "박검토"
|
||||||
},
|
},
|
||||||
"note": null
|
"note": null
|
||||||
}
|
}
|
||||||
@@ -1733,7 +1837,7 @@
|
|||||||
"user": {
|
"user": {
|
||||||
"id": 7,
|
"id": 7,
|
||||||
"name": "김승인",
|
"name": "김승인",
|
||||||
"employee_no": "E2025001",
|
"employee_id": "E2025001",
|
||||||
"email": "approver@example.com",
|
"email": "approver@example.com",
|
||||||
"primary_group": {
|
"primary_group": {
|
||||||
"id": 3,
|
"id": 3,
|
||||||
@@ -1765,6 +1869,7 @@
|
|||||||
- 잘못된 자격 증명: `invalid credentials`
|
- 잘못된 자격 증명: `invalid credentials`
|
||||||
- 비활성 계정 접근: `account is inactive`
|
- 비활성 계정 접근: `account is inactive`
|
||||||
- 만료된 토큰: `token expired`
|
- 만료된 토큰: `token expired`
|
||||||
|
- 비밀번호 변경 등으로 무효화된 토큰: `token revoked`
|
||||||
- 재사용·서명 오류: `invalid token`
|
- 재사용·서명 오류: `invalid token`
|
||||||
|
|
||||||
### 8.1 대시보드 요약
|
### 8.1 대시보드 요약
|
||||||
@@ -1798,7 +1903,7 @@
|
|||||||
],
|
],
|
||||||
"pending_approvals": [
|
"pending_approvals": [
|
||||||
{
|
{
|
||||||
"approval_id": 5005,
|
"approval_id": 5001,
|
||||||
"approval_no": "APP-202511100005",
|
"approval_no": "APP-202511100005",
|
||||||
"title": "출고 결재",
|
"title": "출고 결재",
|
||||||
"step_summary": "2단계/3단계 진행중",
|
"step_summary": "2단계/3단계 진행중",
|
||||||
@@ -1809,8 +1914,6 @@
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `pending_approvals[].approval_id`는 결재 상세 조회(`GET /approvals/{id}`)에 사용되는 `approvals.id` 값을 그대로 노출한다.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. 구현 참고
|
## 9. 구현 참고
|
||||||
|
|||||||
@@ -17,9 +17,15 @@
|
|||||||
- 제품 1개는 반드시 1개의 벤더에 소속 (`products.vendor_id` 필수).
|
- 제품 1개는 반드시 1개의 벤더에 소속 (`products.vendor_id` 필수).
|
||||||
- **트랜잭션 1건당 결재 1건**(1:1, 소프트삭제 제외).
|
- **트랜잭션 1건당 결재 1건**(1:1, 소프트삭제 제외).
|
||||||
- 결재는 **승인자 순서(`approval_steps.step_order`)대로**만 진행.
|
- 결재는 **승인자 순서(`approval_steps.step_order`)대로**만 진행.
|
||||||
|
- 대시보드 대기 결재 요약은 상세 조회 연계를 위해 각 항목의 `approval_id`(= `approvals.id`)를 포함한다.
|
||||||
|
- 결재 목록 응답은 각 항목의 `id`(= `approvals.id`)를 항상 노출하여 상세 조회 트리거로 사용한다.
|
||||||
- 각 단계 상태가 **blocking**이면 다음 단계로 이동 불가.
|
- 각 단계 상태가 **blocking**이면 다음 단계로 이동 불가.
|
||||||
- 트랜잭션에는 **여러 고객사**를 연결할 수 있음(역할 없음).
|
- 트랜잭션에는 **여러 고객사**를 연결할 수 있음(역할 없음).
|
||||||
- 모든 직원은 **그룹**에 속하며(`employees.group_id`), 그룹-메뉴 권한(`group_menu_permissions`)으로 메뉴별 CRUD 가능 여부가 결정됨.
|
- 모든 로그인 사용자는 **users** 테이블 행과 매핑되며(`users.group_id`), 그룹-메뉴 권한(`group_menu_permissions`)으로 메뉴별 CRUD 가능 여부가 결정됨.
|
||||||
|
- `users.employee_id`는 영문/숫자 4~32자 정규식(`^[A-Za-z0-9]{4,32}$`)을 따라야 하며, 대소문자 구분 없이 유니크하다.
|
||||||
|
- 신규 사용자는 생성 직후 `force_password_change=true`로 저장하고, 최초 로그인에서 비밀번호를 변경하지 않으면 다른 기능을 사용할 수 없다.
|
||||||
|
- 비밀번호는 길이 8~24자에 대문자·소문자·숫자·일반 특수문자를 각각 1자 이상 포함해야 하며, 성공적으로 변경되면 기존 세션을 즉시 만료한다.
|
||||||
|
- 최고 관리자 계정 `terabits`는 삭제하지 않고 최상위 권한을 유지하며, 관리자 비밀번호 재설정은 임시 비밀번호 이메일 발송과 `force_password_change=true` 설정을 동시에 수행한다.
|
||||||
- 고객사는 **유형**을 `is_partner`/`is_general` 플래그로 구분하며 둘 중 하나 이상이 true여야 함(기본: 일반 true, 파트너 false).
|
- 고객사는 **유형**을 `is_partner`/`is_general` 플래그로 구분하며 둘 중 하나 이상이 true여야 함(기본: 일반 true, 파트너 false).
|
||||||
- 고객사는 담당자 이름(`contact_name`)을 별도 관리하며 고객 응답에 항상 포함.
|
- 고객사는 담당자 이름(`contact_name`)을 별도 관리하며 고객 응답에 항상 포함.
|
||||||
- 반복되는 결재 라인은 **결재 템플릿**으로 저장 후 호출하여 재사용 가능.
|
- 반복되는 결재 라인은 **결재 템플릿**으로 저장 후 호출하여 재사용 가능.
|
||||||
@@ -53,13 +59,13 @@ approval_actions ||--o{ approval_histories : acted_as
|
|||||||
|
|
||||||
approval_templates ||--o{ approval_template_steps : has_sequence
|
approval_templates ||--o{ approval_template_steps : has_sequence
|
||||||
|
|
||||||
employees ||--o{ approvals : requested_by
|
users ||--o{ approvals : requested_by
|
||||||
employees ||--o{ approval_steps : assigned_to
|
users ||--o{ approval_steps : assigned_to
|
||||||
employees ||--o{ approval_histories : actor
|
users ||--o{ approval_histories : actor
|
||||||
employees ||--o{ stock_transactions : created_by
|
users ||--o{ stock_transactions : created_by
|
||||||
employees ||--o{ approval_templates : authored
|
users ||--o{ approval_templates : authored
|
||||||
employees ||--o{ approval_template_steps : template_approver
|
users ||--o{ approval_template_steps : template_approver
|
||||||
groups ||--o{ employees : members
|
groups ||--o{ users : members
|
||||||
groups ||--o{ group_menu_permissions : controls
|
groups ||--o{ group_menu_permissions : controls
|
||||||
menus ||--o{ group_menu_permissions : target
|
menus ||--o{ group_menu_permissions : target
|
||||||
zipcodes ||--o{ warehouses : located
|
zipcodes ||--o{ warehouses : located
|
||||||
@@ -123,6 +129,8 @@ zipcodes ||--o{ customers : addressed
|
|||||||
| created_at | 생성일시 | timestamp | - | now() | Y | | | |
|
| created_at | 생성일시 | timestamp | - | now() | Y | | | |
|
||||||
| updated_at | 변경일시 | timestamp | - | now() | Y | | | |
|
| updated_at | 변경일시 | timestamp | - | now() | Y | | | |
|
||||||
|
|
||||||
|
> API 기본 응답(`GET /approval-templates`, `GET /approval-templates/{id}`)은 작성자 요약(`created_by { id, employee_id, name }`)을 항상 포함하며, `include=created_by` 없이도 반환된다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 3.3 `customers` (고객사)
|
### 3.3 `customers` (고객사)
|
||||||
@@ -154,25 +162,33 @@ zipcodes ||--o{ customers : addressed
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 3.4 `employees` (사원)
|
### 3.4 `users` (사용자)
|
||||||
| 영문테이블명 | 한글테이블명 |
|
| 영문테이블명 | 한글테이블명 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| employees | 사원 |
|
| users | 사용자 |
|
||||||
|
|
||||||
| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK |
|
| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK |
|
||||||
|---|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|---|
|
||||||
| id | 사원ID | bigint | - | identity | Y | Y | Y | - |
|
| id | 사용자ID | bigint | - | identity | Y | Y | Y | - |
|
||||||
| employee_no | 사번 | varchar | 30 | - | Y | (부분유니크: is_deleted=false) | N | - |
|
| employee_id | 사번 | varchar | 32 | - | Y | (부분유니크: is_deleted=false) | N | - |
|
||||||
| employee_name | 성명 | varchar | 100 | - | Y | | | |
|
| name | 이름 | varchar | 100 | - | Y | | | |
|
||||||
| email | 이메일 | varchar | 100 | - | N | Y | | |
|
| email | 이메일 | varchar | 150 | - | Y | Y | | |
|
||||||
| mobile_no | 모바일번호 | varchar | 20 | - | N | | | |
|
| phone | 연락처 | varchar | 30 | - | N | | | |
|
||||||
| group_id | 그룹ID | bigint | - | - | Y | | | groups.id |
|
| group_id | 그룹ID | bigint | - | - | Y | | | groups.id |
|
||||||
|
| password_hash | 비밀번호해시 | varchar | 255 | - | Y | | | - |
|
||||||
|
| password_updated_at | 비밀번호변경일시 | timestamp | - | now() | Y | | | - |
|
||||||
|
| force_password_change | 비밀번호강제변경 | boolean | - | false | Y | | | |
|
||||||
| note | 비고 | text | - | - | N | | | - |
|
| note | 비고 | text | - | - | N | | | - |
|
||||||
| is_active | 사용여부 | boolean | - | true | Y | | | |
|
| is_active | 사용여부 | boolean | - | true | Y | | | |
|
||||||
| is_deleted | 삭제여부 | boolean | - | false | Y | | | |
|
| is_deleted | 삭제여부 | boolean | - | false | Y | | | |
|
||||||
| created_at | 생성일시 | timestamp | - | now() | Y | | | |
|
| created_at | 생성일시 | timestamp | - | now() | Y | | | |
|
||||||
| updated_at | 변경일시 | timestamp | - | now() | Y | | | |
|
| updated_at | 변경일시 | timestamp | - | now() | Y | | | |
|
||||||
|
|
||||||
|
> `employee_id`는 영문/숫자 4~32자(`^[A-Za-z0-9]{4,32}$`)로 제한하며 대소문자를 구분하지 않는 유니크 인덱스를 적용한다.
|
||||||
|
> 이메일은 소문자로 저장하고 유니크해야 하며, 연락처는 국제 전화번호 포맷을 허용한다.
|
||||||
|
> 신규 생성 시 `force_password_change=true`로 저장하고, 최초 로그인 후 비밀번호 변경 시 `force_password_change=false` 및 `password_updated_at`을 현재 시각으로 갱신한다.
|
||||||
|
> 최고 관리자 계정 `terabits`는 삭제하지 않고 유지하며, 비밀번호 재설정 시 이메일 발송과 즉시 비밀번호 강제 변경 상태로 전환한다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 3.5 `zipcodes` (우편번호)
|
### 3.5 `zipcodes` (우편번호)
|
||||||
@@ -260,7 +276,7 @@ zipcodes ||--o{ customers : addressed
|
|||||||
| created_at | 생성일시 | timestamp | - | now() | Y | | | |
|
| created_at | 생성일시 | timestamp | - | now() | Y | | | |
|
||||||
| updated_at | 변경일시 | timestamp | - | now() | Y | | | |
|
| updated_at | 변경일시 | timestamp | - | now() | Y | | | |
|
||||||
|
|
||||||
> `group_menu_permissions`를 통해 각 그룹별 메뉴 CRUD 권한을 정의하며, 사원은 `employees.group_id`로 그룹에 연결됨.
|
> `group_menu_permissions`를 통해 각 그룹별 메뉴 CRUD 권한을 정의하며, 사용자는 `users.group_id`로 그룹에 연결된다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -381,7 +397,7 @@ zipcodes ||--o{ customers : addressed
|
|||||||
| transaction_status_id | 트랜잭션상태ID | bigint | - | - | Y | | | transaction_statuses.id |
|
| transaction_status_id | 트랜잭션상태ID | bigint | - | - | Y | | | transaction_statuses.id |
|
||||||
| warehouse_id | 창고ID | bigint | - | - | Y | | | warehouses.id |
|
| warehouse_id | 창고ID | bigint | - | - | Y | | | warehouses.id |
|
||||||
| transaction_date | 처리일자 | date | - | current_date | Y | | | - |
|
| transaction_date | 처리일자 | date | - | current_date | Y | | | - |
|
||||||
| created_by_id | 작성자ID | bigint | - | - | N | | | employees.id |
|
| created_by_id | 작성자ID | bigint | - | - | N | | | users.id |
|
||||||
| note | 비고 | text | - | - | N | | | - |
|
| note | 비고 | text | - | - | N | | | - |
|
||||||
| is_active | 사용여부 | boolean | - | true | Y | | | |
|
| is_active | 사용여부 | boolean | - | true | Y | | | |
|
||||||
| is_deleted | 삭제여부 | boolean | - | false | Y | | | |
|
| is_deleted | 삭제여부 | boolean | - | false | Y | | | |
|
||||||
@@ -391,6 +407,7 @@ zipcodes ||--o{ customers : addressed
|
|||||||
> 주의: **벤더ID 없음**. 벤더 정보는 라인의 `product_id`가 가리키는 `products.vendor_id`로 파생.
|
> 주의: **벤더ID 없음**. 벤더 정보는 라인의 `product_id`가 가리키는 `products.vendor_id`로 파생.
|
||||||
> 번호 발급: 서버가 `TRX-YYYYMMDDNNNN` 형식으로 `transaction_no`를 생성하며 클라이언트 입력을 허용하지 않는다.
|
> 번호 발급: 서버가 `TRX-YYYYMMDDNNNN` 형식으로 `transaction_no`를 생성하며 클라이언트 입력을 허용하지 않는다.
|
||||||
> 목록 조회는 `customer_id` 쿼리 파라미터를 지원해 특정 고객이 연결된 트랜잭션만 필터링할 수 있다. (2024-10 갱신)
|
> 목록 조회는 `customer_id` 쿼리 파라미터를 지원해 특정 고객이 연결된 트랜잭션만 필터링할 수 있다. (2024-10 갱신)
|
||||||
|
> 작성자(`created_by_id`)는 로그인 세션의 사용자 ID를 사용하며, API 요청 본문에 전달된 다른 값은 무시하거나 검증 오류로 처리한다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -492,7 +509,7 @@ zipcodes ||--o{ customers : addressed
|
|||||||
| approval_no | 결재번호 | varchar | 30 | - | Y | (부분유니크: is_deleted=false) | N | - |
|
| approval_no | 결재번호 | varchar | 30 | - | Y | (부분유니크: is_deleted=false) | N | - |
|
||||||
| approval_status_id | 전체결재상태ID | bigint | - | - | Y | | | approval_statuses.id |
|
| approval_status_id | 전체결재상태ID | bigint | - | - | Y | | | approval_statuses.id |
|
||||||
| current_step_id | 현재단계ID | bigint | - | - | N | | | approval_steps.id |
|
| current_step_id | 현재단계ID | bigint | - | - | N | | | approval_steps.id |
|
||||||
| requested_by_id | 상신자ID | bigint | - | - | Y | | | employees.id |
|
| requested_by_id | 상신자ID | bigint | - | - | Y | | | users.id |
|
||||||
| requested_at | 상신일시 | timestamp | - | now() | Y | | | - |
|
| requested_at | 상신일시 | timestamp | - | now() | Y | | | - |
|
||||||
| decided_at | 최종결정일시 | timestamp | - | - | N | | | - |
|
| decided_at | 최종결정일시 | timestamp | - | - | N | | | - |
|
||||||
| note | 비고 | text | - | - | N | | | - |
|
| note | 비고 | text | - | - | N | | | - |
|
||||||
@@ -515,7 +532,7 @@ zipcodes ||--o{ customers : addressed
|
|||||||
| id | 단계ID | bigint | - | identity | Y | Y | Y | - |
|
| id | 단계ID | bigint | - | identity | Y | Y | Y | - |
|
||||||
| approval_id | 결재ID | bigint | - | - | Y | | | approvals.id |
|
| approval_id | 결재ID | bigint | - | - | Y | | | approvals.id |
|
||||||
| step_order | 단계순서 | integer | - | 1 | Y | (복합유니크: approval_id, step_order, is_deleted) | N | - |
|
| step_order | 단계순서 | integer | - | 1 | Y | (복합유니크: approval_id, step_order, is_deleted) | N | - |
|
||||||
| approver_id | 승인자ID | bigint | - | - | Y | | | employees.id |
|
| approver_id | 승인자ID | bigint | - | - | Y | | | users.id |
|
||||||
| step_status_id | 단계상태ID | bigint | - | - | Y | | | approval_statuses.id |
|
| step_status_id | 단계상태ID | bigint | - | - | Y | | | approval_statuses.id |
|
||||||
| assigned_at | 배정일시 | timestamp | - | now() | Y | | | - |
|
| assigned_at | 배정일시 | timestamp | - | now() | Y | | | - |
|
||||||
| decided_at | 결정일시 | timestamp | - | - | N | | | - |
|
| decided_at | 결정일시 | timestamp | - | - | N | | | - |
|
||||||
@@ -537,7 +554,7 @@ zipcodes ||--o{ customers : addressed
|
|||||||
| id | 이력ID | bigint | - | identity | Y | Y | Y | - |
|
| id | 이력ID | bigint | - | identity | Y | Y | Y | - |
|
||||||
| approval_id | 결재ID | bigint | - | - | Y | | | approvals.id |
|
| approval_id | 결재ID | bigint | - | - | Y | | | approvals.id |
|
||||||
| approval_step_id | 결재단계ID | bigint | - | - | Y | | | approval_steps.id |
|
| approval_step_id | 결재단계ID | bigint | - | - | Y | | | approval_steps.id |
|
||||||
| approver_id | 승인자ID | bigint | - | - | Y | | | employees.id |
|
| approver_id | 승인자ID | bigint | - | - | Y | | | users.id |
|
||||||
| approval_action_id | 결재행위ID | bigint | - | - | Y | | | approval_actions.id |
|
| approval_action_id | 결재행위ID | bigint | - | - | Y | | | approval_actions.id |
|
||||||
| from_status_id | 변경전상태ID | bigint | - | - | N | | | approval_statuses.id |
|
| from_status_id | 변경전상태ID | bigint | - | - | N | | | approval_statuses.id |
|
||||||
| to_status_id | 변경후상태ID | bigint | - | - | Y | | | approval_statuses.id |
|
| to_status_id | 변경후상태ID | bigint | - | - | Y | | | approval_statuses.id |
|
||||||
@@ -561,7 +578,7 @@ zipcodes ||--o{ customers : addressed
|
|||||||
| template_code | 템플릿코드 | varchar | 30 | - | Y | (부분유니크: is_deleted=false) | N | - |
|
| template_code | 템플릿코드 | varchar | 30 | - | Y | (부분유니크: is_deleted=false) | N | - |
|
||||||
| template_name | 템플릿명 | varchar | 100 | - | Y | | | |
|
| template_name | 템플릿명 | varchar | 100 | - | Y | | | |
|
||||||
| description | 설명 | varchar | 255 | - | N | | | - |
|
| description | 설명 | varchar | 255 | - | N | | | - |
|
||||||
| created_by_id | 작성자ID | bigint | - | - | Y | | | employees.id |
|
| created_by_id | 작성자ID | bigint | - | - | Y | | | users.id |
|
||||||
| note | 비고 | text | - | - | N | | | - |
|
| note | 비고 | text | - | - | N | | | - |
|
||||||
| is_active | 사용여부 | boolean | - | true | Y | | | |
|
| is_active | 사용여부 | boolean | - | true | Y | | | |
|
||||||
| is_deleted | 삭제여부 | boolean | - | false | Y | | | |
|
| is_deleted | 삭제여부 | boolean | - | false | Y | | | |
|
||||||
@@ -580,7 +597,7 @@ zipcodes ||--o{ customers : addressed
|
|||||||
| id | 템플릿단계ID | bigint | - | identity | Y | Y | Y | - |
|
| id | 템플릿단계ID | bigint | - | identity | Y | Y | Y | - |
|
||||||
| template_id | 템플릿ID | bigint | - | - | Y | (복합유니크: template_id, step_order, is_deleted) | N | approval_templates.id |
|
| template_id | 템플릿ID | bigint | - | - | Y | (복합유니크: template_id, step_order, is_deleted) | N | approval_templates.id |
|
||||||
| step_order | 단계순서 | integer | - | 1 | Y | (복합유니크: template_id, step_order, is_deleted) | N | - |
|
| step_order | 단계순서 | integer | - | 1 | Y | (복합유니크: template_id, step_order, is_deleted) | N | - |
|
||||||
| approver_id | 승인자ID | bigint | - | - | Y | | | employees.id |
|
| approver_id | 승인자ID | bigint | - | - | Y | | | users.id |
|
||||||
| note | 비고 | text | - | - | N | | | - |
|
| note | 비고 | text | - | - | N | | | - |
|
||||||
| is_active | 사용여부 | boolean | - | true | Y | | | |
|
| is_active | 사용여부 | boolean | - | true | Y | | | |
|
||||||
| is_deleted | 삭제여부 | boolean | - | false | Y | | | |
|
| is_deleted | 삭제여부 | boolean | - | false | Y | | | |
|
||||||
@@ -593,13 +610,13 @@ zipcodes ||--o{ customers : addressed
|
|||||||
|
|
||||||
## 4) FK 관계 (source → target)
|
## 4) FK 관계 (source → target)
|
||||||
- `menus.parent_menu_id` → `menus.id`
|
- `menus.parent_menu_id` → `menus.id`
|
||||||
- `employees.group_id` → `groups.id`
|
- `users.group_id` → `groups.id`
|
||||||
- `group_menu_permissions.group_id` → `groups.id`
|
- `group_menu_permissions.group_id` → `groups.id`
|
||||||
- `group_menu_permissions.menu_id` → `menus.id`
|
- `group_menu_permissions.menu_id` → `menus.id`
|
||||||
- `products.vendor_id` → `vendors.id`
|
- `products.vendor_id` → `vendors.id`
|
||||||
- `products.uom_id` → `uoms.id`
|
- `products.uom_id` → `uoms.id`
|
||||||
- `stock_transactions.warehouse_id` → `warehouses.id`
|
- `stock_transactions.warehouse_id` → `warehouses.id`
|
||||||
- `stock_transactions.created_by_id` → `employees.id`
|
- `stock_transactions.created_by_id` → `users.id`
|
||||||
- `stock_transactions.transaction_type_id` → `transaction_types.id`
|
- `stock_transactions.transaction_type_id` → `transaction_types.id`
|
||||||
- `stock_transactions.transaction_status_id` → `transaction_statuses.id`
|
- `stock_transactions.transaction_status_id` → `transaction_statuses.id`
|
||||||
- `transaction_lines.transaction_id` → `stock_transactions.id`
|
- `transaction_lines.transaction_id` → `stock_transactions.id`
|
||||||
@@ -609,19 +626,19 @@ zipcodes ||--o{ customers : addressed
|
|||||||
- `approvals.transaction_id` → `stock_transactions.id`
|
- `approvals.transaction_id` → `stock_transactions.id`
|
||||||
- `approvals.approval_status_id` → `approval_statuses.id`
|
- `approvals.approval_status_id` → `approval_statuses.id`
|
||||||
- `approvals.current_step_id` → `approval_steps.id`
|
- `approvals.current_step_id` → `approval_steps.id`
|
||||||
- `approvals.requested_by_id` → `employees.id`
|
- `approvals.requested_by_id` → `users.id`
|
||||||
- `approval_steps.approval_id` → `approvals.id`
|
- `approval_steps.approval_id` → `approvals.id`
|
||||||
- `approval_steps.approver_id` → `employees.id`
|
- `approval_steps.approver_id` → `users.id`
|
||||||
- `approval_steps.step_status_id` → `approval_statuses.id`
|
- `approval_steps.step_status_id` → `approval_statuses.id`
|
||||||
- `approval_histories.approval_id` → `approvals.id`
|
- `approval_histories.approval_id` → `approvals.id`
|
||||||
- `approval_histories.approval_step_id` → `approval_steps.id`
|
- `approval_histories.approval_step_id` → `approval_steps.id`
|
||||||
- `approval_histories.approver_id` → `employees.id`
|
- `approval_histories.approver_id` → `users.id`
|
||||||
- `approval_histories.approval_action_id` → `approval_actions.id`
|
- `approval_histories.approval_action_id` → `approval_actions.id`
|
||||||
- `approval_histories.from_status_id` → `approval_statuses.id`
|
- `approval_histories.from_status_id` → `approval_statuses.id`
|
||||||
- `approval_histories.to_status_id` → `approval_statuses.id`
|
- `approval_histories.to_status_id` → `approval_statuses.id`
|
||||||
- `approval_templates.created_by_id` → `employees.id`
|
- `approval_templates.created_by_id` → `users.id`
|
||||||
- `approval_template_steps.template_id` → `approval_templates.id`
|
- `approval_template_steps.template_id` → `approval_templates.id`
|
||||||
- `approval_template_steps.approver_id` → `employees.id`
|
- `approval_template_steps.approver_id` → `users.id`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -633,13 +650,17 @@ zipcodes ||--o{ customers : addressed
|
|||||||
- 단계 전이는 **현재 단계**에서만 수행 가능. blocking 상태에서는 차기 이동 불가.
|
- 단계 전이는 **현재 단계**에서만 수행 가능. blocking 상태에서는 차기 이동 불가.
|
||||||
- 수량/단가 음수 금지(CHECK).
|
- 수량/단가 음수 금지(CHECK).
|
||||||
- 그룹이 비활성(`is_active=false`) 또는 삭제되면 해당 그룹 권한/구성원은 즉시 무효 처리.
|
- 그룹이 비활성(`is_active=false`) 또는 삭제되면 해당 그룹 권한/구성원은 즉시 무효 처리.
|
||||||
- 사원의 소속 그룹(`employees.group_id`)에서 해당 메뉴에 대한 `can_create|can_update|can_delete` 중 하나라도 true이면 그 동작을 수행할 수 있음.
|
- 사용자의 소속 그룹(`users.group_id`)에서 해당 메뉴에 대한 `can_create|can_update|can_delete` 중 하나라도 true이면 그 동작을 수행할 수 있음.
|
||||||
|
- `users.employee_id`는 앞뒤 공백을 제거한 뒤 대소문자 구분 없이 중복 검증하며, 저장 시 대문자로 정규화한다.
|
||||||
|
- 자기 정보 수정(`PATCH /users/me`)에서는 `phone`, `email`, `password`만 변경할 수 있고, `password` 변경 시 기존 비밀번호 검증이 필수다.
|
||||||
|
- 비밀번호 재설정(관리자)은 8자 영문 대소문자+숫자 조합을 생성하고 이메일 발송 큐에 푸시한 뒤 `force_password_change=true`, `password_updated_at=now()`로 기록한다.
|
||||||
|
- 비밀번호 변경이 성공하면 기존 세션은 즉시 만료하고, 강제 로그아웃 알림을 반환한다. 이후 무효화된 토큰으로 요청하면 `token revoked` 메시지를 반환해야 한다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6) 인덱스/유니크 권장
|
## 6) 인덱스/유니크 권장
|
||||||
- 부분 유니크(또는 복합 유니크)로 소프트 삭제와 공존:
|
- 부분 유니크(또는 복합 유니크)로 소프트 삭제와 공존:
|
||||||
- `vendors(vendor_code)`, `warehouses(warehouse_code)`, `customers(customer_code)`, `employees(employee_no)`, `menus(menu_code)`, `groups(group_name)`, `products(product_code)`, `stock_transactions(transaction_no)`, `approvals(approval_no)`
|
- `vendors(vendor_code)`, `warehouses(warehouse_code)`, `customers(customer_code)`, `users(employee_id)`, `menus(menu_code)`, `groups(group_name)`, `products(product_code)`, `stock_transactions(transaction_no)`, `approvals(approval_no)`
|
||||||
- `group_menu_permissions(group_id, menu_id, is_deleted)`
|
- `group_menu_permissions(group_id, menu_id, is_deleted)`
|
||||||
- `approvals(transaction_id)` — 미삭제 조건에서 1:1 보장
|
- `approvals(transaction_id)` — 미삭제 조건에서 1:1 보장
|
||||||
- `transaction_lines(transaction_id, line_no, is_deleted)`
|
- `transaction_lines(transaction_id, line_no, is_deleted)`
|
||||||
@@ -663,9 +684,10 @@ zipcodes ||--o{ customers : addressed
|
|||||||
4) 부분 유니크 인덱스(`WHERE is_deleted=false`) 또는 `(컬럼, is_deleted)` 복합 유니크 구성.
|
4) 부분 유니크 인덱스(`WHERE is_deleted=false`) 또는 `(컬럼, is_deleted)` 복합 유니크 구성.
|
||||||
5) 기존 결재 이력은 `approval_step_id` 매핑(없으면 1단계로 귀속).
|
5) 기존 결재 이력은 `approval_step_id` 매핑(없으면 1단계로 귀속).
|
||||||
6) `approval_statuses`에 `is_blocking_next`, `is_terminal` 값 시드.
|
6) `approval_statuses`에 `is_blocking_next`, `is_terminal` 값 시드.
|
||||||
7) `menus`, `groups`, `group_menu_permissions` 신규 생성 및 기존 관리자 권한/사원-그룹 매핑을 `employees.group_id`로 이관.
|
7) `users` 테이블에 `employee_id`, `name`, `phone`, `password_hash`, `password_updated_at`, `force_password_change` 컬럼을 추가하고 기존 `employee_no`, `employee_name`, `mobile_no` 데이터를 규칙에 맞게 마이그레이션한다. 그룹 매핑은 `users.group_id`로 유지한다.
|
||||||
8) `zipcodes` 테이블 생성 및 도로명 주소 기준 데이터 적재.
|
8) `menus`, `groups`, `group_menu_permissions` 신규 생성 및 기존 관리자 권한/사용자-그룹 매핑을 `users.group_id`로 유지한다.
|
||||||
9) 모든 테이블에 `note`(text) 컬럼 추가 및 필요한 경우 기본값 NULL 유지.
|
9) `zipcodes` 테이블 생성 및 도로명 주소 기준 데이터 적재.
|
||||||
|
10) 모든 테이블에 `note`(text) 컬럼 추가 및 필요한 경우 기본값 NULL 유지.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
60
doc/user_management_plan.md
Normal file
60
doc/user_management_plan.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# 사용자 계정 리팩터링 실행 계획
|
||||||
|
|
||||||
|
## 배경
|
||||||
|
- 최신 스펙(`stock_approval_system_spec_v4.md`, `stock_approval_system_api_v4.md`)은 `users` 리소스를 기준으로 `employee_id`, `force_password_change`, `password_updated_at` 등을 요구한다.
|
||||||
|
- 현재 백엔드 구현은 `employees` 테이블과 `/employees` API에 머물러 있어 신규 필드/엔드포인트가 부재하며, 로그인 흐름도 `force_password_change` 플래그를 해석하지 않는다.
|
||||||
|
- `doc/user_setting.md`에서 정의된 기능(관리자 생성, 자기 정보 수정, 비밀번호 재설정, 최초 로그인 강제 변경 등)을 지원하려면 스키마/도메인/레포지토리/API/인증 레이어 전반 리팩터링이 필요하다.
|
||||||
|
|
||||||
|
## 변경 범위 설계
|
||||||
|
- **DB 스키마**
|
||||||
|
- `employees` 테이블을 `users`로 리네임하고, 컬럼명을 `employee_id`, `name`, `phone`으로 정규화한다.
|
||||||
|
- `password_updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`, `force_password_change BOOLEAN NOT NULL DEFAULT FALSE` 컬럼 추가.
|
||||||
|
- `employee_id`는 대문자로 정규화하고 `UPPER(employee_id)` 기반 부분 유니크 인덱스를 생성한다.
|
||||||
|
- 관련 인덱스/트리거/시퀀스를 `users_*` 명명으로 교체하고 FK 제약 이름도 정리한다.
|
||||||
|
- 마이그레이션 롤백 시 데이터를 보존하며 컬럼 제거 대신 초기화 전략 명시.
|
||||||
|
- **도메인 & 리포지토리**
|
||||||
|
- `src/domain/employees.rs` → `users.rs`로 파일명/모듈을 교체하고 모든 구조체/필드/검증 로직을 `user_*` 네이밍으로 재작성.
|
||||||
|
- SeaORM 엔티티(`EmployeeRepository`)를 `UserRepository`로 리팩터링하고 신규 필드 매핑, 정규화 로직(대문자 변환 등)을 구현.
|
||||||
|
- `EmployeeSortField::EmployeeNo` 등 정렬 키를 `employee_id` 기준으로 수정, 검색 시 이메일/이름/사번을 모두 지원.
|
||||||
|
- **API**
|
||||||
|
- `/api/v1/users` 스코프로 교체하고 목록/단건/생성/수정/삭제/복구 엔드포인트를 신규 구조에 맞춰 응답.
|
||||||
|
- `PATCH /users/me`, `POST /users/{id}/reset-password` 엔드포인트 추가.
|
||||||
|
- 관리자 전용 `PATCH /users/{id}`에서 `force_password_change` 토글과 그룹 변경을 지원.
|
||||||
|
- **인증**
|
||||||
|
- 로그인/리프레시 응답 사용자 필드를 `employee_id`로 변경하고, `force_password_change=true`일 때 토큰 대신 전용 에러 코드(`password_change_required`)를 반환.
|
||||||
|
- 비밀번호 변경/재설정 성공 시 세션 무효화 훅을 배치하고 `password_updated_at`을 갱신.
|
||||||
|
- **연쇄 영향**
|
||||||
|
- 승인/거래/대시보드 등 사용자 요약 정보를 노출하는 모든 응답 구조체에서 `employee_no`→`employee_id`, `employee_name`→`name`, `mobile_no`→`phone`으로 교체.
|
||||||
|
- OpenAPI 재생성(`backend/docs/openapi.generated.json`) 및 문서 싱크 확인.
|
||||||
|
|
||||||
|
## 단계별 작업 순서
|
||||||
|
1. **마이그레이션 작성 (`migration/010_migrate_employees_to_users.sql`)**
|
||||||
|
- 컬럼/테이블 rename, 새 컬럼 추가, 인덱스/트리거 재정의, 데이터 정규화.
|
||||||
|
2. **도메인/리포지토리/엔티티 리팩터링**
|
||||||
|
- `Employee*` 구조체 및 레포지토리를 `User*`로 일괄 교체.
|
||||||
|
- 정규화/검증 로직(사번 대문자화, 이메일 소문자화, 비밀번호 정책)에 맞춘다.
|
||||||
|
3. **API 계층 업데이트**
|
||||||
|
- `/users` 라우팅, 자기 정보 수정/비밀번호 재설정 엔드포인트 추가.
|
||||||
|
- 응답 스키마를 스펙 문서와 동일하게 맞춘다.
|
||||||
|
4. **인증 및 세션 플로우 확장**
|
||||||
|
- `force_password_change` 처리, 세션 만료 훅, 에러 매핑 도입.
|
||||||
|
5. **연쇄 모듈(승인/트랜잭션/리포트 등) 필드명 치환**
|
||||||
|
- 모든 사용자 관련 요약 구조체와 JSON 필드를 업데이트하고 단위 테스트 보강.
|
||||||
|
6. **문서 & QA 체크**
|
||||||
|
- 변경된 API/스펙 재검증, `doc/frontend_api_alignment_plan.md` 등 연계 문서 업데이트.
|
||||||
|
- 통합 테스트 및 `cargo check`, `cargo fmt`, `cargo clippy`, `cargo test` 수행.
|
||||||
|
|
||||||
|
## 진행 현황 (2025-01-07)
|
||||||
|
- [x] `migration/010_migrate_employees_to_users.sql` 작성 및 컬럼/인덱스/트리거 갱신.
|
||||||
|
- [x] 도메인/레포지토리/인증 계층을 `users` 기준으로 리팩터링하고 비밀번호/사번 검증 로직 반영.
|
||||||
|
- [x] `/api/v1/users` + `/users/me` + `/users/{id}/reset-password` 등 사용자 API 구현 및 기존 `/employees` 제거.
|
||||||
|
- [x] 인증 토큰 강제 갱신 로직과 세션 무효화 훅 연동.
|
||||||
|
- [x] 승인/거래/리포트 응답 내 사용자 요약 구조체 추가 정비 및 통합 테스트 확충.
|
||||||
|
- [x] 문서(`stock_approval_system_api_v4.md`, `stock_approval_system_spec_v4.md`, alignment 보고서) 최종 검수.
|
||||||
|
|
||||||
|
## 중단 대비 메모
|
||||||
|
- `migration/010_migrate_employees_to_users.sql`이 적용된 상태이므로 롤백 시 `employees`→`users` rename 전후 스키마 차이를 반드시 확인할 것.
|
||||||
|
- `/api/v1/users` 엔드포인트가 활성화되어 있으며, JWT `pwd_updated_at` 클레임 기반 세션 무효화가 도입되어 이전 토큰은 비밀번호 변경 직후 사용 불가하다.
|
||||||
|
- 승인/거래/리포트 모듈에서 사용자요약을 읽어가는 경로를 전수 점검 중이므로, 후속 담당자는 변경된 도메인 구조(`ApprovalUserSummary`, `StockTransactionUserSummary` 등)를 참고해 릴레이션 누락이 없는지 점검할 것.
|
||||||
|
- 리포트/승인/재고 레이어의 사용자 요약 회귀 테스트가 `backend/src/adapters/repositories/` 모듈에 추가돼 있으니 실패 시 최근 사용자 필드 변경 여부부터 확인한다.
|
||||||
|
- 통합 테스트(`tests/users/`)는 아직 비어 있으므로, 테스트 생성 시 `tests/users/README.md`에 시나리오를 정리하고 `doc/frontend_backend_alignment_report.md`에 기록을 남긴다.
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
입고 등록/사용자 관리 기능에서 작성자(로그인 사용자) 정보를 정확히 추적하고, 관리자가 신규 사용자를 생성·관리할 수 있도록 백엔드/프런트엔드 동시 진행 항목을 정리했습니다. 기존 인증/세션 흐름과 충돌할 수 있으므로, 아래 항목을 참고해 단계별 검증을 병행하세요.
|
입고 등록/사용자 관리 기능에서 작성자(로그인 사용자) 정보를 정확히 추적하고, 관리자가 신규 사용자를 생성·관리할 수 있도록 백엔드/프런트엔드 동시 진행 항목을 정리했습니다. 기존 인증/세션 흐름과 충돌할 수 있으므로, 아래 항목을 참고해 단계별 검증을 병행하세요.
|
||||||
|
|
||||||
## 요구사항 요약
|
## 요구사항 요약
|
||||||
- 작성자는 현재 로그인한 사용자 계정을 사용한다. (기존 더미 계정 `terabits` 제거 예정)
|
- 작성자는 현재 로그인한 사용자 계정을 사용한다. (기존 더미 계정 `terabits`는 최고 관리자 계정으로 유지하며 삭제하지 않는다)
|
||||||
- 신규 사용자는 관리자만 등록할 수 있으며, 필수 입력 필드는 `employee_id`, `name`, `phone`, `email`, `password`.
|
- 신규 사용자는 관리자만 등록할 수 있으며, 필수 입력 필드는 `employee_id`, `name`, `phone`, `email`, `password`.
|
||||||
- `employee_id`는 영문/숫자만 허용한다. (정규식 예: `^[A-Za-z0-9]{4,32}$`)
|
- `employee_id`는 영문/숫자만 허용한다. (정규식 예: `^[A-Za-z0-9]{4,32}$`)
|
||||||
- 사용자는 등록 후 `phone`, `email`, `password`만 스스로 수정할 수 있다.
|
- 사용자는 등록 후 `phone`, `email`, `password`만 스스로 수정할 수 있다.
|
||||||
@@ -14,12 +14,22 @@
|
|||||||
- 로그인 후 우상단 사용자 메뉴에서 이메일/비밀번호/연락처 변경 가능하며 `저장` 버튼으로 확정한다.
|
- 로그인 후 우상단 사용자 메뉴에서 이메일/비밀번호/연락처 변경 가능하며 `저장` 버튼으로 확정한다.
|
||||||
- 비밀번호 변경 성공 시 즉시 로그아웃 팝업을 띄우고 확인과 동시에 세션을 만료한다. 취소는 허용하지 않는다.
|
- 비밀번호 변경 성공 시 즉시 로그아웃 팝업을 띄우고 확인과 동시에 세션을 만료한다. 취소는 허용하지 않는다.
|
||||||
|
|
||||||
|
## 영향 범위 및 문서 동기화 체크리스트
|
||||||
|
- 사용자 인증, 알림, 입고 등록 플로우 모두에 영향을 준다. 배포 전 후속 회귀 테스트를 위해 `doc/IMPLEMENTATION_TASKS.md`, `doc/frontend_api_alignment_plan.md`, `doc/frontend_backend_alignment_report.md`를 업데이트하고 담당자 서명을 남긴다.
|
||||||
|
- API/DTO 변경은 `doc/stock_approval_system_api_v4.md`, `doc/stock_approval_system_spec_v4.md`와 중복 정의가 없는지 교차 검토한다. 충돌 시 해당 문서를 동시에 수정한다.
|
||||||
|
- UI 사양 변경은 `doc/input_widget_guide.md`, `doc/frontend_auto_numbering_update.md`에서 안내하는 컴포넌트 규칙과 일치해야 한다. 불일치 발견 시 문서와 코드 모두 조정한다.
|
||||||
|
- 새로운 정책 문구는 QA/기획 승인 후 공유하고, 수정 이력은 `doc/DTO_TASKS.md` 또는 관련 변경 로그에 기록한다.
|
||||||
|
- 사이드 이펙트 방지를 위해 세션 만료, 알림(Notify), 이메일 발송 시스템에 대한 변경 감지 테스트를 작성하고, 관련 서비스 운영자에게 사전 알린다.
|
||||||
|
- 작업자는 위 문서들을 포함해 최신 요구사항 문서를 모두 검토한 뒤 개발을 시작하며, 수정한 문서 목록을 PR 설명에 명시한다.
|
||||||
|
|
||||||
## 백엔드 작업 항목
|
## 백엔드 작업 항목
|
||||||
|
|
||||||
### 1. 도메인 및 저장소 구조 정리
|
### 1. 도메인 및 저장소 구조 정리
|
||||||
- `users` 테이블/컬렉션에 `employee_id`, `phone`, `password_hash`, `password_updated_at`, `force_password_change` 필드를 추가한다.
|
- `users` 테이블/컬렉션에 `employee_id`, `phone`, `password_hash`, `password_updated_at`, `force_password_change` 필드를 추가한다.
|
||||||
- `employee_id` 컬럼은 유니크 인덱스를 생성하고 대소문자 구분 여부를 명확히 한다.
|
- `employee_id` 컬럼은 유니크 인덱스를 생성하고 대소문자 구분 여부를 명확히 한다.
|
||||||
- 기존 `terabits` 계정은 마이그레이션 스크립트에서 관리자 권한 유지 여부만 검토하고, 실사용자 생성 이후 제거 플랜을 마련한다.
|
- 기존 `terabits` 계정은 최상위 관리자(Super Admin)로 고정 유지하며, 마이그레이션 스크립트에서 권한 승계가 깨지지 않는지 검증한다. 삭제 작업은 서비스 전환 이후 운영 측에서 수동 처리하도록 문서화한다.
|
||||||
|
- 마이그레이션 파일은 `backend/migrations/2024xxxx_add_user_fields.sql` 형태로 작성하고, 롤백 스크립트에 `DROP COLUMN` 대신 값 초기화를 고려한다.
|
||||||
|
- 도메인 레이어(`lib/domain/auth/entities/user.dart`)와 데이터 레이어 DTO가 새 필드를 반영하도록 Freezed 모델을 갱신한다.
|
||||||
|
|
||||||
### 2. 사용자 생성 API (`POST /users`)
|
### 2. 사용자 생성 API (`POST /users`)
|
||||||
- 관리자 권한 체크 로직을 선행한다.
|
- 관리자 권한 체크 로직을 선행한다.
|
||||||
@@ -27,6 +37,8 @@
|
|||||||
- `password`는 Bcrypt/Scrypt 등 기존 정책에 맞춰 해시 저장한다.
|
- `password`는 Bcrypt/Scrypt 등 기존 정책에 맞춰 해시 저장한다.
|
||||||
- 생성 직후 `force_password_change = true`로 설정하고 응답에 포함한다.
|
- 생성 직후 `force_password_change = true`로 설정하고 응답에 포함한다.
|
||||||
- 사용자 생성 완료 시 비밀번호 초기값을 메일 발송 큐에 전달한다.
|
- 사용자 생성 완료 시 비밀번호 초기값을 메일 발송 큐에 전달한다.
|
||||||
|
- OpenAPI/Swagger 문서(`backend/openapi/user.yaml`)를 업데이트하고, 프런트엔드와 공유할 샘플 응답을 `doc/frontend_api_alignment_plan.md`에 추가한다.
|
||||||
|
- 새 API 연동을 위해 `lib/injection_container.dart`에서 `UserRepository` 구현에 `createUser` 메서드를 공개한다.
|
||||||
|
|
||||||
### 3. 사용자 정보 수정 API
|
### 3. 사용자 정보 수정 API
|
||||||
- **자기 정보 수정 (`PATCH /users/me`)**
|
- **자기 정보 수정 (`PATCH /users/me`)**
|
||||||
@@ -35,6 +47,8 @@
|
|||||||
- **관리자용 수정 (`PATCH /users/{id}`)**
|
- **관리자용 수정 (`PATCH /users/{id}`)**
|
||||||
- 허용 필드: `phone`, `email`, 권한 변경 등 필요한 항목만 열어준다.
|
- 허용 필드: `phone`, `email`, 권한 변경 등 필요한 항목만 열어준다.
|
||||||
- 비밀번호 재설정은 별도 엔드포인트로 분리한다.
|
- 비밀번호 재설정은 별도 엔드포인트로 분리한다.
|
||||||
|
- 비밀번호 변경 시 감사 로그(`audit_logs` 테이블)와 보안 이벤트 버스에 `password_change` 이벤트를 발행해 SIEM과 연계한다.
|
||||||
|
- `PATCH /users/me` 응답에 `forcePasswordChange` 상태를 포함해 프런트 동기화를 용이하게 한다.
|
||||||
|
|
||||||
### 4. 비밀번호 재설정 API (`POST /users/{id}/reset-password`)
|
### 4. 비밀번호 재설정 API (`POST /users/{id}/reset-password`)
|
||||||
- 관리자 권한 필터 적용.
|
- 관리자 권한 필터 적용.
|
||||||
@@ -42,23 +56,31 @@
|
|||||||
- 새 비밀번호 해시 저장 후 `force_password_change = true` 설정.
|
- 새 비밀번호 해시 저장 후 `force_password_change = true` 설정.
|
||||||
- 이메일 발송: 템플릿에 임시 비밀번호 및 최초 로그인 안내 포함.
|
- 이메일 발송: 템플릿에 임시 비밀번호 및 최초 로그인 안내 포함.
|
||||||
- 감사 로깅: 누가 언제 어떤 사용자 비밀번호를 초기화했는지 저장.
|
- 감사 로깅: 누가 언제 어떤 사용자 비밀번호를 초기화했는지 저장.
|
||||||
|
- 임시 비밀번호는 10분 동안만 유효하도록 선택적 만료 토큰을 발급하고, 사용자는 첫 로그인 시 즉시 변경해야 한다는 메시지를 포함한다.
|
||||||
|
- API 오류 응답 코드(권한 없음, 사용자 없음, 메일 전송 실패)를 정의하고 `doc/error_message_guide.md`와 일치시킨다.
|
||||||
|
|
||||||
### 5. 최초 로그인 강제 변경 로직
|
### 5. 최초 로그인 강제 변경 로직
|
||||||
- 로그인 성공 시 `force_password_change`가 `true`면 액세스 토큰 발급 대신 “비밀번호 재설정 필요” 상태 코드/에러 코드를 반환한다.
|
- 로그인 성공 시 `force_password_change`가 `true`면 액세스 토큰 발급 대신 “비밀번호 재설정 필요” 상태 코드/에러 코드를 반환한다.
|
||||||
- 프런트엔드는 이 코드를 받아 전용 비밀번호 변경 화면으로 이동한다.
|
- 프런트엔드는 이 코드를 받아 전용 비밀번호 변경 화면으로 이동한다.
|
||||||
- 기존 Remember-me/세션 로직과의 호환성을 검증한다.
|
- 기존 Remember-me/세션 로직과의 호환성을 검증한다.
|
||||||
|
- 모바일/웹 동시 로그인 시 한쪽에서 비밀번호 변경하면 다른 기기 세션도 강제 만료되도록 Redis 세션 캐시를 무효화한다.
|
||||||
|
- 인증 로그(`auth_events`)에 `force_password_change_triggered` 이벤트를 남겨 추적 가능하도록 한다.
|
||||||
|
|
||||||
### 6. 이메일 발송 및 템플릿
|
### 6. 이메일 발송 및 템플릿
|
||||||
- 신규 계정 생성/비밀번호 재설정 공통 템플릿을 작성하고 변수: `name`, `employee_id`, `temp_password`, `reset_url`.
|
- 신규 계정 생성/비밀번호 재설정 공통 템플릿을 작성하고 변수: `name`, `employee_id`, `temp_password`, `reset_url`.
|
||||||
- SMTP/메일 서비스 환경 변수 재점검(`MAIL_FROM`, `RESET_URL_BASE` 등).
|
- SMTP/메일 서비스 환경 변수 재점검(`MAIL_FROM`, `RESET_URL_BASE` 등).
|
||||||
- QA 환경에서는 메일 전송을 sandbox로 대체하고 로그로 임시 비밀번호를 출력한다.
|
- QA 환경에서는 메일 전송을 sandbox로 대체하고 로그로 임시 비밀번호를 출력한다.
|
||||||
|
- 템플릿 초안은 `doc/qa/email_templates/user_password_reset.md`에 저장하고, PR 시점에 QA 승인 코멘트를 첨부한다.
|
||||||
|
- 메일 발송 실패 시 재시도 큐를 3회까지 두고, 실패 알림을 Slack/Notify로 전송한다.
|
||||||
|
|
||||||
### 7. 테스트 & 배포 체크리스트
|
### 7. 테스트 & 배포 체크리스트
|
||||||
- [ ] 사용자 생성, 자기 정보 수정, 관리자 초기화 API 통합 테스트 작성.
|
- [x] 사용자 생성, 자기 정보 수정, 관리자 초기화 API 통합 테스트 작성. (테스트: `test/features/masters/user/data/user_repository_remote_test.dart`)
|
||||||
- [ ] 비밀번호 정책 유효성 테스트 (허용/거부 케이스) 구현.
|
- [x] 비밀번호 정책 유효성 테스트 (허용/거부 케이스) 구현. (테스트: `test/core/validation/password_rules_test.dart`)
|
||||||
- [ ] 마이그레이션 스크립트와 롤백 스크립트 준비.
|
- [ ] 마이그레이션 스크립트와 롤백 스크립트 준비.
|
||||||
- [ ] 배포 전 staging에서 실제 메일 발송 여부 검증.
|
- [ ] 배포 전 staging에서 실제 메일 발송 여부 검증.
|
||||||
- [ ] 기존 로그인 세션/토큰 구조와 충돌 여부 점검.
|
- [ ] 기존 로그인 세션/토큰 구조와 충돌 여부 점검.
|
||||||
|
- [ ] Notify 파이프라인(`notify.py`)을 통해 릴리스 직후 운영자에게 변경 내역을 알린다.
|
||||||
|
- [ ] 장애 발생 시 롤백 전략(임시 비밀번호 재발급 차단, 구 계정 복구)을 문서화한다.
|
||||||
|
|
||||||
## 프런트엔드 작업 항목
|
## 프런트엔드 작업 항목
|
||||||
|
|
||||||
@@ -67,32 +89,46 @@
|
|||||||
- 신규 등록 모달/페이지에 입력 필드를 추가하고 프런트 검증(정규식, 필수 여부)을 구현한다.
|
- 신규 등록 모달/페이지에 입력 필드를 추가하고 프런트 검증(정규식, 필수 여부)을 구현한다.
|
||||||
- 제출 시 비밀번호 정책 위반 시 프론트에서 선제적으로 에러 메시지 표시(한글 메시지, 정책 안내 문구 포함).
|
- 제출 시 비밀번호 정책 위반 시 프론트에서 선제적으로 에러 메시지 표시(한글 메시지, 정책 안내 문구 포함).
|
||||||
- 생성 성공 후 토스트 및 리스트 리프레시, 임시 비밀번호가 이메일로 발송됨을 명시한다.
|
- 생성 성공 후 토스트 및 리스트 리프레시, 임시 비밀번호가 이메일로 발송됨을 명시한다.
|
||||||
|
- UI 구현 시 `lib/features/user_management/presentation/widgets/shad_user_table.dart`와 `ShadTable` 컴포넌트를 기반으로 열 구성을 추가한다.
|
||||||
|
- 상태 관리는 `lib/features/user_management/presentation/controllers/user_controller.dart`에 `createUser` 액션을 확장하고, 에러 핸들링을 중앙화한다.
|
||||||
|
- (2025-10-24) 신규 등록 모달에 임시 비밀번호/이메일/연락처 필수 검증을 적용하고 목록 액션에 비밀번호 재설정 버튼과 확인 다이얼로그를 추가했다. (`lib/features/masters/user/presentation/pages/user_page.dart`, 테스트: `test/features/masters/user/presentation/pages/user_page_test.dart`)
|
||||||
|
|
||||||
### 2. 관리자 > 사용자 상세 보기
|
### 2. 관리자 > 사용자 상세 보기
|
||||||
- 비밀번호 재설정 버튼 추가: 클릭 시 확인 다이얼로그 → API 호출 → 성공 토스트.
|
- 비밀번호 재설정 버튼 추가: 클릭 시 확인 다이얼로그 → API 호출 → 성공 토스트.
|
||||||
- 다이얼로그 문구에 “임시 비밀번호가 이메일로 발송된다”는 안내 포함.
|
- 다이얼로그 문구에 “임시 비밀번호가 이메일로 발송된다”는 안내 포함.
|
||||||
- 감사 로그 필요 시 별도 이벤트 추적(`analytics`/`Sentry breadcrumb`)을 연동한다.
|
- 감사 로그 필요 시 별도 이벤트 추적(`analytics`/`Sentry breadcrumb`)을 연동한다.
|
||||||
|
- 다이얼로그는 `SuperportShadDialog`를 사용하고, 컴포넌트는 `lib/widgets/dialogs/` 밑에 재사용 가능하게 분리한다.
|
||||||
|
- API 응답(메시지/실패 코드)은 `lib/core/network/api_error_mapper.dart`에서 한글 메시지로 변환한다.
|
||||||
|
|
||||||
### 3. 우상단 사용자 메뉴 개선
|
### 3. 우상단 사용자 메뉴 개선
|
||||||
- `내 정보` 패널에서 이메일/연락처 수정 필드를 제공하고, 변경 시 Dirty 상태 감지 → `저장` 버튼 활성화.
|
- `내 정보` 패널에서 이메일/연락처 수정 필드를 제공하고, 변경 시 Dirty 상태 감지 → `저장` 버튼 활성화.
|
||||||
- 저장 성공 후 사용자 상태 스토어/Provider를 갱신하여 헤더/다른 화면과 동기화.
|
- 저장 성공 후 사용자 상태 스토어/Provider를 갱신하여 헤더/다른 화면과 동기화.
|
||||||
- 비밀번호 변경 진입 버튼을 분리하고, 모달 또는 전용 페이지에서 3개 입력 필드를 제공한다.
|
- 비밀번호 변경 진입 버튼을 분리하고, 모달 또는 전용 페이지에서 3개 입력 필드를 제공한다.
|
||||||
|
- `lib/features/profile/presentation/pages/profile_page.dart`에서 폼 상태와 검증 로직을 `lib/features/profile/presentation/controllers/profile_controller.dart`로 분리한다.
|
||||||
|
- `lib/core/validation/password_rules.dart`에 비밀번호 정책 검증 유틸을 추가하고, 모든 비밀번호 입력 필드에서 재사용한다.
|
||||||
|
- (2025-10-24) 상단 내 정보 모달에 이메일/연락처 저장과 비밀번호 변경(강제 로그아웃) 흐름을 구현하고 테스트를 추가했다. (`lib/widgets/app_shell.dart`, 테스트: `test/widgets/app_shell_test.dart`)
|
||||||
|
|
||||||
### 4. 비밀번호 변경 플로우
|
### 4. 비밀번호 변경 플로우
|
||||||
- `현재 비밀번호`, `새 비밀번호`, `새 비밀번호 확인` 필드와 실시간 정책 검증(대/소문자, 숫자, 특수문자) UI를 구현한다.
|
- `현재 비밀번호`, `새 비밀번호`, `새 비밀번호 확인` 필드와 실시간 정책 검증(대/소문자, 숫자, 특수문자) UI를 구현한다.
|
||||||
- 저장 시 API 호출 → 성공하면 즉시 비밀번호 변경 완료 다이얼로그/스낵바 → **강제 로그아웃 팝업** 표출.
|
- 저장 시 API 호출 → 성공하면 즉시 비밀번호 변경 완료 다이얼로그/스낵바 → **강제 로그아웃 팝업** 표출.
|
||||||
- 팝업은 확인 버튼만 제공하며, 클릭 시 `authController.signOut()` 호출 후 로그인 페이지로 리다이렉트.
|
- 팝업은 확인 버튼만 제공하며, 클릭 시 `authController.signOut()` 호출 후 로그인 페이지로 리다이렉트.
|
||||||
- 실패 케이스 처리: 기존 비밀번호 불일치, 정책 위반, 서버 에러 각각에 맞는 오류 메시지.
|
- 실패 케이스 처리: 기존 비밀번호 불일치, 정책 위반, 서버 에러 각각에 맞는 오류 메시지.
|
||||||
|
- 팝업 UI는 `lib/features/auth/presentation/widgets/logout_alert.dart`로 분리하여 테스트 가능하도록 한다.
|
||||||
|
- 로그아웃 후 캐시된 사용자 정보는 `UserSessionStore.clear()`를 호출해 잔여 데이터를 모두 제거한다.
|
||||||
|
|
||||||
### 5. 최초 로그인 경로 처리
|
### 5. 최초 로그인 경로 처리
|
||||||
- 로그인 성공 응답이 “비밀번호 변경 필요” 상태이면 인증 토큰을 저장하지 않고, 전용 비밀번호 변경 화면으로 라우팅.
|
- 로그인 성공 응답이 “비밀번호 변경 필요” 상태이면 인증 토큰을 저장하지 않고, 전용 비밀번호 변경 화면으로 라우팅.
|
||||||
- 비밀번호 변경 완료 후에는 정상 로그인 플로우 재시도(저장된 자격 증명 재사용 금지).
|
- 비밀번호 변경 완료 후에는 정상 로그인 플로우 재시도(저장된 자격 증명 재사용 금지).
|
||||||
|
- 라우터(`lib/core/router/app_router.dart`)에 `forcePasswordChange` 상태를 처리하는 보호 라우트를 추가한다.
|
||||||
|
- 상태 관리(`AuthNotifier`)는 비밀번호 변경 필요 상태를 저장하고, 다른 화면 접근 시 가드를 적용한다.
|
||||||
|
|
||||||
### 6. 공통 사항
|
### 6. 공통 사항
|
||||||
- `employee_id`는 읽기 전용 표시로 유지하며, 자기 정보 수정 화면에서는 비활성화한다.
|
- `employee_id`는 읽기 전용 표시로 유지하며, 자기 정보 수정 화면에서는 비활성화한다.
|
||||||
- 폼 검증 메시지는 `lib/core/validation` 또는 기존 유틸 모듈에 추가하고 재사용한다.
|
- 폼 검증 메시지는 `lib/core/validation` 또는 기존 유틸 모듈에 추가하고 재사용한다.
|
||||||
- API 타입 정의(DTO/모델) 업데이트: `forcePasswordChange` 플래그, `phone`/`email` 수정 필드 등 반영.
|
- API 타입 정의(DTO/모델) 업데이트: `forcePasswordChange` 플래그, `phone`/`email` 수정 필드 등 반영.
|
||||||
- 테스트: 관리자 화면 위젯 테스트, 비밀번호 변경 위젯 테스트, 상태 관리 유닛 테스트 작성.
|
- 테스트: 관리자 화면 위젯 테스트, 비밀번호 변경 위젯 테스트, 상태 관리 유닛 테스트 작성.
|
||||||
|
- `dart run build_runner build --delete-conflicting-outputs` 명령으로 DTO 업데이트 후 생성물을 재생성한다.
|
||||||
|
- Storybook/샘플 화면이 있다면 사용자 메뉴/다이얼로그 스토리를 갱신한다.
|
||||||
|
|
||||||
### 7. QA 체크리스트
|
### 7. QA 체크리스트
|
||||||
- [ ] 신규 사용자 생성 시 임시 비밀번호 안내 모달과 이메일 발송 메세지가 노출된다.
|
- [ ] 신규 사용자 생성 시 임시 비밀번호 안내 모달과 이메일 발송 메세지가 노출된다.
|
||||||
@@ -100,10 +136,16 @@
|
|||||||
- [ ] 비밀번호 변경 후 로그아웃 팝업이 표시되고, 확인 시 실제 로그아웃 된다.
|
- [ ] 비밀번호 변경 후 로그아웃 팝업이 표시되고, 확인 시 실제 로그아웃 된다.
|
||||||
- [ ] 이메일/연락처 저장 시 즉시 프로필 정보가 갱신된다.
|
- [ ] 이메일/연락처 저장 시 즉시 프로필 정보가 갱신된다.
|
||||||
- [ ] 관리자 비밀번호 재설정 후 사용자가 로그인 시 새 임시 비밀번호가 요구된다.
|
- [ ] 관리자 비밀번호 재설정 후 사용자가 로그인 시 새 임시 비밀번호가 요구된다.
|
||||||
|
- [ ] API 실패(메일·세션·권한 오류) 시 사용자에게 명확한 한글 메시지가 노출된다.
|
||||||
|
- [ ] 프로필 데이터 캐시가 오래된 경우 새 정보로 갱신되는지 확인한다.
|
||||||
|
- [ ] 접근 권한이 없는 사용자가 관리자 화면에 진입하면 즉시 차단된다.
|
||||||
|
|
||||||
## 동시 작업 및 커뮤니케이션 가이드
|
## 동시 작업 및 커뮤니케이션 가이드
|
||||||
- 백엔드는 임시로 `force_password_change` 상태를 반환하는 Mock 응답을 제공하고, 프런트엔드는 이를 기준으로 화면을 선행 구현한다.
|
- 개발 순서는 **백엔드 구현과 검증을 선행**하고, API 스펙이 확정·배포된 이후 프런트엔드 개발을 진행한다. 프런트에서 임의 Mock을 사용하지 말고, 백엔드가 전달한 실제 응답 계약을 기반으로 연동한다.
|
||||||
|
- 백엔드 구현 완료 시점에 API 계약서와 샘플 응답을 공유하고, 프런트엔드는 이를 수신한 뒤 화면/로직을 개발한다.
|
||||||
- API 스펙 변경 사항은 `doc/frontend_api_alignment_plan.md`에 연동 기록을 추가하고, DTO 변경은 `dart run build_runner` 실행 시점 합의 후 진행한다.
|
- API 스펙 변경 사항은 `doc/frontend_api_alignment_plan.md`에 연동 기록을 추가하고, DTO 변경은 `dart run build_runner` 실행 시점 합의 후 진행한다.
|
||||||
- 이메일 템플릿, 비밀번호 정책 문구 등 사용자 노출 텍스트는 `product/QA` 승인 후 배포한다.
|
- 이메일 템플릿, 비밀번호 정책 문구 등 사용자 노출 텍스트는 `product/QA` 승인 후 배포한다.
|
||||||
- 배포 순서: 백엔드 마이그레이션 → 신규 API 배포 → 프런트 배포 → 더미 계정 정리.
|
- 배포 순서: 백엔드 마이그레이션 → 신규 API 배포 → 프런트 배포 → 더미 계정 정리.
|
||||||
- 사이드 이펙트 대비: 로그인 세션 만료, 캐시된 사용자 정보, 기존 Admin UI 권한 체크 로직 변경 시 전체 기능 회귀 테스트를 수행한다.
|
- 사이드 이펙트 대비: 로그인 세션 만료, 캐시된 사용자 정보, 기존 Admin UI 권한 체크 로직 변경 시 전체 기능 회귀 테스트를 수행한다.
|
||||||
|
- 변경 사항은 `notify.py` 워크플로로 전달하고, 영향 범위(입고 등록, 승인 플로우, 보고서 출력)를 명시한다.
|
||||||
|
- 프런트·백엔드 책임자는 매일 스탠드업에서 진행 현황과 문서 업데이트 여부를 공유한다.
|
||||||
|
|||||||
55
lib/core/validation/password_rules.dart
Normal file
55
lib/core/validation/password_rules.dart
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
/// 비밀번호 정책을 검증하기 위한 규칙 모음.
|
||||||
|
class PasswordRules {
|
||||||
|
PasswordRules._();
|
||||||
|
|
||||||
|
/// 허용 최소 길이.
|
||||||
|
static const int minLength = 8;
|
||||||
|
|
||||||
|
/// 허용 최대 길이.
|
||||||
|
static const int maxLength = 24;
|
||||||
|
|
||||||
|
static final RegExp _uppercase = RegExp(r'[A-Z]');
|
||||||
|
static final RegExp _lowercase = RegExp(r'[a-z]');
|
||||||
|
static final RegExp _digit = RegExp(r'[0-9]');
|
||||||
|
static final RegExp _special = RegExp(
|
||||||
|
"[!@#\$%\\^&*()_+\\-={}\\[\\]\\\\|:;\"'<>,.?/~`]",
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 입력이 모든 비밀번호 규칙을 만족하는지 검사한다.
|
||||||
|
static bool isValid(String value) => validate(value).isEmpty;
|
||||||
|
|
||||||
|
/// 비밀번호 정책 위반 항목을 반환한다.
|
||||||
|
static List<PasswordRuleViolation> validate(String value) {
|
||||||
|
final violations = <PasswordRuleViolation>[];
|
||||||
|
final length = value.length;
|
||||||
|
if (length < minLength) {
|
||||||
|
violations.add(PasswordRuleViolation.tooShort);
|
||||||
|
}
|
||||||
|
if (length > maxLength) {
|
||||||
|
violations.add(PasswordRuleViolation.tooLong);
|
||||||
|
}
|
||||||
|
if (!_uppercase.hasMatch(value)) {
|
||||||
|
violations.add(PasswordRuleViolation.missingUppercase);
|
||||||
|
}
|
||||||
|
if (!_lowercase.hasMatch(value)) {
|
||||||
|
violations.add(PasswordRuleViolation.missingLowercase);
|
||||||
|
}
|
||||||
|
if (!_digit.hasMatch(value)) {
|
||||||
|
violations.add(PasswordRuleViolation.missingDigit);
|
||||||
|
}
|
||||||
|
if (!_special.hasMatch(value)) {
|
||||||
|
violations.add(PasswordRuleViolation.missingSpecial);
|
||||||
|
}
|
||||||
|
return violations;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 비밀번호 규칙 위반 유형.
|
||||||
|
enum PasswordRuleViolation {
|
||||||
|
tooShort,
|
||||||
|
tooLong,
|
||||||
|
missingUppercase,
|
||||||
|
missingLowercase,
|
||||||
|
missingDigit,
|
||||||
|
missingSpecial,
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
|
|||||||
import '../../../core/network/interceptors/auth_interceptor.dart';
|
import '../../../core/network/interceptors/auth_interceptor.dart';
|
||||||
import '../../../core/services/token_storage.dart';
|
import '../../../core/services/token_storage.dart';
|
||||||
import '../domain/entities/auth_session.dart';
|
import '../domain/entities/auth_session.dart';
|
||||||
|
import '../domain/entities/authenticated_user.dart';
|
||||||
import '../domain/entities/login_request.dart';
|
import '../domain/entities/login_request.dart';
|
||||||
import '../domain/repositories/auth_repository.dart';
|
import '../domain/repositories/auth_repository.dart';
|
||||||
|
|
||||||
@@ -69,6 +70,22 @@ class AuthService extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 현재 세션의 사용자 정보를 갱신한다.
|
||||||
|
void updateSessionUser(AuthenticatedUser user) {
|
||||||
|
final current = _session;
|
||||||
|
if (current == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_session = AuthSession(
|
||||||
|
accessToken: current.accessToken,
|
||||||
|
refreshToken: current.refreshToken,
|
||||||
|
expiresAt: current.expiresAt,
|
||||||
|
user: user,
|
||||||
|
permissions: current.permissions,
|
||||||
|
);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _persistSession(AuthSession session) async {
|
Future<void> _persistSession(AuthSession session) async {
|
||||||
_session = session;
|
_session = session;
|
||||||
await _tokenStorage.writeAccessToken(session.accessToken);
|
await _tokenStorage.writeAccessToken(session.accessToken);
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ AuthenticatedUser _parseUser(Map<String, dynamic> json) {
|
|||||||
final name = _readString(json, 'name') ?? '';
|
final name = _readString(json, 'name') ?? '';
|
||||||
final employeeNo = _readString(json, 'employee_no');
|
final employeeNo = _readString(json, 'employee_no');
|
||||||
final email = _readString(json, 'email');
|
final email = _readString(json, 'email');
|
||||||
|
final phone = _readString(json, 'phone');
|
||||||
final group = JsonUtils.extractMap(
|
final group = JsonUtils.extractMap(
|
||||||
json,
|
json,
|
||||||
keys: const ['group', 'primary_group'],
|
keys: const ['group', 'primary_group'],
|
||||||
@@ -105,6 +106,7 @@ AuthenticatedUser _parseUser(Map<String, dynamic> json) {
|
|||||||
name: name,
|
name: name,
|
||||||
employeeNo: employeeNo,
|
employeeNo: employeeNo,
|
||||||
email: email,
|
email: email,
|
||||||
|
phone: phone,
|
||||||
primaryGroupId: _readOptionalInt(group, 'id'),
|
primaryGroupId: _readOptionalInt(group, 'id'),
|
||||||
primaryGroupName: _readString(group, 'name'),
|
primaryGroupName: _readString(group, 'name'),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ class AuthenticatedUser {
|
|||||||
required this.name,
|
required this.name,
|
||||||
this.employeeNo,
|
this.employeeNo,
|
||||||
this.email,
|
this.email,
|
||||||
|
this.phone,
|
||||||
this.primaryGroupId,
|
this.primaryGroupId,
|
||||||
this.primaryGroupName,
|
this.primaryGroupName,
|
||||||
});
|
});
|
||||||
@@ -21,9 +22,31 @@ class AuthenticatedUser {
|
|||||||
/// 이메일
|
/// 이메일
|
||||||
final String? email;
|
final String? email;
|
||||||
|
|
||||||
|
/// 연락처
|
||||||
|
final String? phone;
|
||||||
|
|
||||||
/// 기본 소속 그룹 ID
|
/// 기본 소속 그룹 ID
|
||||||
final int? primaryGroupId;
|
final int? primaryGroupId;
|
||||||
|
|
||||||
/// 기본 소속 그룹명
|
/// 기본 소속 그룹명
|
||||||
final String? primaryGroupName;
|
final String? primaryGroupName;
|
||||||
|
|
||||||
|
AuthenticatedUser copyWith({
|
||||||
|
String? name,
|
||||||
|
String? employeeNo,
|
||||||
|
String? email,
|
||||||
|
String? phone,
|
||||||
|
int? primaryGroupId,
|
||||||
|
String? primaryGroupName,
|
||||||
|
}) {
|
||||||
|
return AuthenticatedUser(
|
||||||
|
id: id,
|
||||||
|
name: name ?? this.name,
|
||||||
|
employeeNo: employeeNo ?? this.employeeNo,
|
||||||
|
email: email ?? this.email,
|
||||||
|
phone: phone ?? this.phone,
|
||||||
|
primaryGroupId: primaryGroupId ?? this.primaryGroupId,
|
||||||
|
primaryGroupName: primaryGroupName ?? this.primaryGroupName,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ class UserDto {
|
|||||||
this.isActive = true,
|
this.isActive = true,
|
||||||
this.isDeleted = false,
|
this.isDeleted = false,
|
||||||
this.note,
|
this.note,
|
||||||
|
this.forcePasswordChange = false,
|
||||||
|
this.passwordUpdatedAt,
|
||||||
this.createdAt,
|
this.createdAt,
|
||||||
this.updatedAt,
|
this.updatedAt,
|
||||||
});
|
});
|
||||||
@@ -28,6 +30,8 @@ class UserDto {
|
|||||||
final bool isActive;
|
final bool isActive;
|
||||||
final bool isDeleted;
|
final bool isDeleted;
|
||||||
final String? note;
|
final String? note;
|
||||||
|
final bool forcePasswordChange;
|
||||||
|
final DateTime? passwordUpdatedAt;
|
||||||
final DateTime? createdAt;
|
final DateTime? createdAt;
|
||||||
final DateTime? updatedAt;
|
final DateTime? updatedAt;
|
||||||
|
|
||||||
@@ -35,16 +39,22 @@ class UserDto {
|
|||||||
factory UserDto.fromJson(Map<String, dynamic> json) {
|
factory UserDto.fromJson(Map<String, dynamic> json) {
|
||||||
return UserDto(
|
return UserDto(
|
||||||
id: json['id'] as int?,
|
id: json['id'] as int?,
|
||||||
employeeNo: json['employee_no'] as String,
|
employeeNo:
|
||||||
employeeName: json['employee_name'] as String,
|
json['employee_id'] as String? ??
|
||||||
|
json['employee_no'] as String? ??
|
||||||
|
'-',
|
||||||
|
employeeName:
|
||||||
|
json['name'] as String? ?? json['employee_name'] as String? ?? '-',
|
||||||
email: json['email'] as String?,
|
email: json['email'] as String?,
|
||||||
mobileNo: json['mobile_no'] as String?,
|
mobileNo: json['phone'] as String? ?? json['mobile_no'] as String?,
|
||||||
group: json['group'] is Map<String, dynamic>
|
group: json['group'] is Map<String, dynamic>
|
||||||
? UserGroupDto.fromJson(json['group'] as Map<String, dynamic>)
|
? UserGroupDto.fromJson(json['group'] as Map<String, dynamic>)
|
||||||
: null,
|
: null,
|
||||||
isActive: (json['is_active'] as bool?) ?? true,
|
isActive: (json['is_active'] as bool?) ?? true,
|
||||||
isDeleted: (json['is_deleted'] as bool?) ?? false,
|
isDeleted: (json['is_deleted'] as bool?) ?? false,
|
||||||
note: json['note'] as String?,
|
note: json['note'] as String?,
|
||||||
|
forcePasswordChange: (json['force_password_change'] as bool?) ?? false,
|
||||||
|
passwordUpdatedAt: _parseDate(json['password_updated_at']),
|
||||||
createdAt: _parseDate(json['created_at']),
|
createdAt: _parseDate(json['created_at']),
|
||||||
updatedAt: _parseDate(json['updated_at']),
|
updatedAt: _parseDate(json['updated_at']),
|
||||||
);
|
);
|
||||||
@@ -61,6 +71,8 @@ class UserDto {
|
|||||||
isActive: isActive,
|
isActive: isActive,
|
||||||
isDeleted: isDeleted,
|
isDeleted: isDeleted,
|
||||||
note: note,
|
note: note,
|
||||||
|
forcePasswordChange: forcePasswordChange,
|
||||||
|
passwordUpdatedAt: passwordUpdatedAt,
|
||||||
createdAt: createdAt,
|
createdAt: createdAt,
|
||||||
updatedAt: updatedAt,
|
updatedAt: updatedAt,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class UserRepositoryRemote implements UserRepository {
|
|||||||
|
|
||||||
final ApiClient _api;
|
final ApiClient _api;
|
||||||
|
|
||||||
static const _basePath = '${ApiRoutes.apiV1}/employees';
|
static const _basePath = '${ApiRoutes.apiV1}/users';
|
||||||
|
|
||||||
/// 사용자 목록을 조회한다.
|
/// 사용자 목록을 조회한다.
|
||||||
@override
|
@override
|
||||||
@@ -31,7 +31,7 @@ class UserRepositoryRemote implements UserRepository {
|
|||||||
'page_size': pageSize,
|
'page_size': pageSize,
|
||||||
if (query != null && query.isNotEmpty) 'q': query,
|
if (query != null && query.isNotEmpty) 'q': query,
|
||||||
if (groupId != null) 'group_id': groupId,
|
if (groupId != null) 'group_id': groupId,
|
||||||
if (isActive != null) 'active': isActive,
|
if (isActive != null) 'is_active': isActive,
|
||||||
'include': 'group',
|
'include': 'group',
|
||||||
},
|
},
|
||||||
options: Options(responseType: ResponseType.json),
|
options: Options(responseType: ResponseType.json),
|
||||||
@@ -62,6 +62,27 @@ class UserRepositoryRemote implements UserRepository {
|
|||||||
return UserDto.fromJson(_api.unwrapAsMap(response)).toEntity();
|
return UserDto.fromJson(_api.unwrapAsMap(response)).toEntity();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 로그인한 사용자의 정보를 수정한다.
|
||||||
|
@override
|
||||||
|
Future<UserAccount> updateMe(UserProfileUpdateInput input) async {
|
||||||
|
final response = await _api.patch<Map<String, dynamic>>(
|
||||||
|
'$_basePath/me',
|
||||||
|
data: input.toPayload(),
|
||||||
|
options: Options(responseType: ResponseType.json),
|
||||||
|
);
|
||||||
|
return UserDto.fromJson(_api.unwrapAsMap(response)).toEntity();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 관리자가 특정 사용자의 비밀번호를 재설정한다.
|
||||||
|
@override
|
||||||
|
Future<UserAccount> resetPassword(int id) async {
|
||||||
|
final response = await _api.post<Map<String, dynamic>>(
|
||||||
|
'$_basePath/$id/reset-password',
|
||||||
|
options: Options(responseType: ResponseType.json),
|
||||||
|
);
|
||||||
|
return UserDto.fromJson(_api.unwrapAsMap(response)).toEntity();
|
||||||
|
}
|
||||||
|
|
||||||
/// 사용자를 삭제한다.
|
/// 사용자를 삭제한다.
|
||||||
@override
|
@override
|
||||||
Future<void> delete(int id) async {
|
Future<void> delete(int id) async {
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ class UserAccount {
|
|||||||
this.isActive = true,
|
this.isActive = true,
|
||||||
this.isDeleted = false,
|
this.isDeleted = false,
|
||||||
this.note,
|
this.note,
|
||||||
|
this.forcePasswordChange = false,
|
||||||
|
this.passwordUpdatedAt,
|
||||||
this.createdAt,
|
this.createdAt,
|
||||||
this.updatedAt,
|
this.updatedAt,
|
||||||
});
|
});
|
||||||
@@ -23,6 +25,8 @@ class UserAccount {
|
|||||||
final bool isActive;
|
final bool isActive;
|
||||||
final bool isDeleted;
|
final bool isDeleted;
|
||||||
final String? note;
|
final String? note;
|
||||||
|
final bool forcePasswordChange;
|
||||||
|
final DateTime? passwordUpdatedAt;
|
||||||
final DateTime? createdAt;
|
final DateTime? createdAt;
|
||||||
final DateTime? updatedAt;
|
final DateTime? updatedAt;
|
||||||
|
|
||||||
@@ -37,6 +41,8 @@ class UserAccount {
|
|||||||
bool? isActive,
|
bool? isActive,
|
||||||
bool? isDeleted,
|
bool? isDeleted,
|
||||||
String? note,
|
String? note,
|
||||||
|
bool? forcePasswordChange,
|
||||||
|
DateTime? passwordUpdatedAt,
|
||||||
DateTime? createdAt,
|
DateTime? createdAt,
|
||||||
DateTime? updatedAt,
|
DateTime? updatedAt,
|
||||||
}) {
|
}) {
|
||||||
@@ -50,6 +56,8 @@ class UserAccount {
|
|||||||
isActive: isActive ?? this.isActive,
|
isActive: isActive ?? this.isActive,
|
||||||
isDeleted: isDeleted ?? this.isDeleted,
|
isDeleted: isDeleted ?? this.isDeleted,
|
||||||
note: note ?? this.note,
|
note: note ?? this.note,
|
||||||
|
forcePasswordChange: forcePasswordChange ?? this.forcePasswordChange,
|
||||||
|
passwordUpdatedAt: passwordUpdatedAt ?? this.passwordUpdatedAt,
|
||||||
createdAt: createdAt ?? this.createdAt,
|
createdAt: createdAt ?? this.createdAt,
|
||||||
updatedAt: updatedAt ?? this.updatedAt,
|
updatedAt: updatedAt ?? this.updatedAt,
|
||||||
);
|
);
|
||||||
@@ -73,6 +81,8 @@ class UserInput {
|
|||||||
this.email,
|
this.email,
|
||||||
this.mobileNo,
|
this.mobileNo,
|
||||||
this.isActive = true,
|
this.isActive = true,
|
||||||
|
this.forcePasswordChange,
|
||||||
|
this.password,
|
||||||
this.note,
|
this.note,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -82,18 +92,48 @@ class UserInput {
|
|||||||
final String? email;
|
final String? email;
|
||||||
final String? mobileNo;
|
final String? mobileNo;
|
||||||
final bool isActive;
|
final bool isActive;
|
||||||
|
final bool? forcePasswordChange;
|
||||||
|
final String? password;
|
||||||
final String? note;
|
final String? note;
|
||||||
|
|
||||||
/// API 요청 바디로 직렬화한다.
|
/// API 요청 바디로 직렬화한다.
|
||||||
Map<String, dynamic> toPayload() {
|
Map<String, dynamic> toPayload() {
|
||||||
return {
|
return {
|
||||||
'employee_no': employeeNo,
|
'employee_id': employeeNo,
|
||||||
'employee_name': employeeName,
|
'name': employeeName,
|
||||||
'group_id': groupId,
|
'group_id': groupId,
|
||||||
'email': email,
|
if (email != null) 'email': email,
|
||||||
'mobile_no': mobileNo,
|
if (mobileNo != null) 'phone': mobileNo,
|
||||||
'is_active': isActive,
|
'is_active': isActive,
|
||||||
'note': note,
|
if (forcePasswordChange != null)
|
||||||
|
'force_password_change': forcePasswordChange,
|
||||||
|
if (password != null) 'password': password,
|
||||||
|
if (note != null) 'note': note,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 자기 정보 수정 입력 모델.
|
||||||
|
class UserProfileUpdateInput {
|
||||||
|
const UserProfileUpdateInput({
|
||||||
|
this.email,
|
||||||
|
this.phone,
|
||||||
|
this.password,
|
||||||
|
this.currentPassword,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String? email;
|
||||||
|
final String? phone;
|
||||||
|
final String? password;
|
||||||
|
final String? currentPassword;
|
||||||
|
|
||||||
|
/// 자기 정보 수정 요청 바디를 직렬화한다.
|
||||||
|
Map<String, dynamic> toPayload() {
|
||||||
|
return {
|
||||||
|
if (email != null) 'email': email,
|
||||||
|
if (phone != null) 'phone': phone,
|
||||||
|
if (password != null) 'password': password,
|
||||||
|
if (currentPassword != null) 'current_password': currentPassword,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ abstract class UserRepository {
|
|||||||
/// 사용자 정보를 수정한다.
|
/// 사용자 정보를 수정한다.
|
||||||
Future<UserAccount> update(int id, UserInput input);
|
Future<UserAccount> update(int id, UserInput input);
|
||||||
|
|
||||||
|
/// 로그인한 본인 정보를 수정한다.
|
||||||
|
Future<UserAccount> updateMe(UserProfileUpdateInput input);
|
||||||
|
|
||||||
|
/// 특정 사용자의 비밀번호를 재설정한다.
|
||||||
|
Future<UserAccount> resetPassword(int id);
|
||||||
|
|
||||||
/// 사용자를 삭제한다.
|
/// 사용자를 삭제한다.
|
||||||
Future<void> delete(int id);
|
Future<void> delete(int id);
|
||||||
|
|
||||||
|
|||||||
@@ -163,6 +163,23 @@ class UserController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 관리자가 사용자의 비밀번호를 재설정한다.
|
||||||
|
Future<UserAccount?> resetPassword(int id) async {
|
||||||
|
_setSubmitting(true);
|
||||||
|
try {
|
||||||
|
final updated = await _userRepository.resetPassword(id);
|
||||||
|
await fetch(page: _result?.page ?? 1);
|
||||||
|
return updated;
|
||||||
|
} catch (error) {
|
||||||
|
final failure = Failure.from(error);
|
||||||
|
_errorMessage = failure.describe();
|
||||||
|
notifyListeners();
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
_setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 사용자를 삭제한다.
|
/// 사용자를 삭제한다.
|
||||||
Future<bool> delete(int id) async {
|
Future<bool> delete(int id) async {
|
||||||
_setSubmitting(true);
|
_setSubmitting(true);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import 'package:superport_v2/widgets/components/superport_pagination_controls.da
|
|||||||
|
|
||||||
import '../../../../../core/config/environment.dart';
|
import '../../../../../core/config/environment.dart';
|
||||||
import '../../../../../core/permissions/permission_manager.dart';
|
import '../../../../../core/permissions/permission_manager.dart';
|
||||||
|
import '../../../../../core/validation/password_rules.dart';
|
||||||
import '../../../../../widgets/spec_page.dart';
|
import '../../../../../widgets/spec_page.dart';
|
||||||
import '../../../group/domain/entities/group.dart';
|
import '../../../group/domain/entities/group.dart';
|
||||||
import '../../../group/domain/repositories/group_repository.dart';
|
import '../../../group/domain/repositories/group_repository.dart';
|
||||||
@@ -318,6 +319,9 @@ class _UserEnabledPageState extends State<_UserEnabledPage> {
|
|||||||
: (user) => _openUserForm(context, user: user),
|
: (user) => _openUserForm(context, user: user),
|
||||||
onDelete: _controller.isSubmitting ? null : _confirmDelete,
|
onDelete: _controller.isSubmitting ? null : _confirmDelete,
|
||||||
onRestore: _controller.isSubmitting ? null : _restoreUser,
|
onRestore: _controller.isSubmitting ? null : _restoreUser,
|
||||||
|
onResetPassword: _controller.isSubmitting
|
||||||
|
? null
|
||||||
|
: _confirmResetPassword,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -367,13 +371,17 @@ class _UserEnabledPageState extends State<_UserEnabledPage> {
|
|||||||
final mobileController = TextEditingController(
|
final mobileController = TextEditingController(
|
||||||
text: existing?.mobileNo ?? '',
|
text: existing?.mobileNo ?? '',
|
||||||
);
|
);
|
||||||
|
final passwordController = TextEditingController();
|
||||||
final noteController = TextEditingController(text: existing?.note ?? '');
|
final noteController = TextEditingController(text: existing?.note ?? '');
|
||||||
final groupNotifier = ValueNotifier<int?>(existing?.group?.id);
|
final groupNotifier = ValueNotifier<int?>(existing?.group?.id);
|
||||||
final isActiveNotifier = ValueNotifier<bool>(existing?.isActive ?? true);
|
final isActiveNotifier = ValueNotifier<bool>(existing?.isActive ?? true);
|
||||||
final saving = ValueNotifier<bool>(false);
|
final saving = ValueNotifier<bool>(false);
|
||||||
final codeError = ValueNotifier<String?>(null);
|
final codeError = ValueNotifier<String?>(null);
|
||||||
final nameError = ValueNotifier<String?>(null);
|
final nameError = ValueNotifier<String?>(null);
|
||||||
|
final emailError = ValueNotifier<String?>(null);
|
||||||
|
final phoneError = ValueNotifier<String?>(null);
|
||||||
final groupError = ValueNotifier<String?>(null);
|
final groupError = ValueNotifier<String?>(null);
|
||||||
|
final passwordError = ValueNotifier<String?>(null);
|
||||||
|
|
||||||
if (groupNotifier.value == null && _controller.groups.length == 1) {
|
if (groupNotifier.value == null && _controller.groups.length == 1) {
|
||||||
groupNotifier.value = _controller.groups.first.id;
|
groupNotifier.value = _controller.groups.first.id;
|
||||||
@@ -398,13 +406,30 @@ class _UserEnabledPageState extends State<_UserEnabledPage> {
|
|||||||
final note = noteController.text.trim();
|
final note = noteController.text.trim();
|
||||||
final groupId = groupNotifier.value;
|
final groupId = groupNotifier.value;
|
||||||
|
|
||||||
|
if (!isEdit) {
|
||||||
|
final password = passwordController.text;
|
||||||
|
if (password.isEmpty) {
|
||||||
|
passwordError.value = '임시 비밀번호를 입력하세요.';
|
||||||
|
} else {
|
||||||
|
final violations = PasswordRules.validate(password);
|
||||||
|
passwordError.value = violations.isEmpty
|
||||||
|
? null
|
||||||
|
: _describePasswordViolations(violations);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
codeError.value = code.isEmpty ? '사번을 입력하세요.' : null;
|
codeError.value = code.isEmpty ? '사번을 입력하세요.' : null;
|
||||||
nameError.value = name.isEmpty ? '성명을 입력하세요.' : null;
|
nameError.value = name.isEmpty ? '성명을 입력하세요.' : null;
|
||||||
|
emailError.value = email.isEmpty ? '이메일을 입력하세요.' : null;
|
||||||
|
phoneError.value = mobile.isEmpty ? '연락처를 입력하세요.' : null;
|
||||||
groupError.value = groupId == null ? '그룹을 선택하세요.' : null;
|
groupError.value = groupId == null ? '그룹을 선택하세요.' : null;
|
||||||
|
|
||||||
if (codeError.value != null ||
|
if (codeError.value != null ||
|
||||||
nameError.value != null ||
|
nameError.value != null ||
|
||||||
groupError.value != null) {
|
emailError.value != null ||
|
||||||
|
phoneError.value != null ||
|
||||||
|
groupError.value != null ||
|
||||||
|
(!isEdit && passwordError.value != null)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,6 +445,8 @@ class _UserEnabledPageState extends State<_UserEnabledPage> {
|
|||||||
email: email.isEmpty ? null : email,
|
email: email.isEmpty ? null : email,
|
||||||
mobileNo: mobile.isEmpty ? null : mobile,
|
mobileNo: mobile.isEmpty ? null : mobile,
|
||||||
isActive: isActiveNotifier.value,
|
isActive: isActiveNotifier.value,
|
||||||
|
password: isEdit ? null : passwordController.text,
|
||||||
|
forcePasswordChange: isEdit ? null : true,
|
||||||
note: note.isEmpty ? null : note,
|
note: note.isEmpty ? null : note,
|
||||||
);
|
);
|
||||||
final response = isEdit
|
final response = isEdit
|
||||||
@@ -470,6 +497,7 @@ class _UserEnabledPageState extends State<_UserEnabledPage> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
ShadInput(
|
ShadInput(
|
||||||
|
key: const ValueKey('user_form_employee'),
|
||||||
controller: codeController,
|
controller: codeController,
|
||||||
readOnly: isEdit,
|
readOnly: isEdit,
|
||||||
onChanged: (_) {
|
onChanged: (_) {
|
||||||
@@ -503,6 +531,7 @@ class _UserEnabledPageState extends State<_UserEnabledPage> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
ShadInput(
|
ShadInput(
|
||||||
|
key: const ValueKey('user_form_name'),
|
||||||
controller: nameController,
|
controller: nameController,
|
||||||
onChanged: (_) {
|
onChanged: (_) {
|
||||||
if (nameController.text.trim().isNotEmpty) {
|
if (nameController.text.trim().isNotEmpty) {
|
||||||
@@ -525,21 +554,120 @@ class _UserEnabledPageState extends State<_UserEnabledPage> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
if (!isEdit) ...[
|
||||||
_FormField(
|
const SizedBox(height: 16),
|
||||||
label: '이메일',
|
ValueListenableBuilder<String?>(
|
||||||
child: ShadInput(
|
valueListenable: passwordError,
|
||||||
controller: emailController,
|
builder: (_, errorText, __) {
|
||||||
keyboardType: TextInputType.emailAddress,
|
return _FormField(
|
||||||
|
label: '임시 비밀번호',
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
ShadInput(
|
||||||
|
key: const ValueKey('user_form_password'),
|
||||||
|
controller: passwordController,
|
||||||
|
obscureText: true,
|
||||||
|
placeholder: const Text('임시 비밀번호를 입력하세요'),
|
||||||
|
onChanged: (_) {
|
||||||
|
final value = passwordController.text;
|
||||||
|
if (value.isEmpty) {
|
||||||
|
passwordError.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (PasswordRules.isValid(value)) {
|
||||||
|
passwordError.value = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
'비밀번호는 8~24자이며 대문자, 소문자, 숫자, 특수문자를 각각 1자 이상 포함해야 합니다.',
|
||||||
|
style: theme.textTheme.muted,
|
||||||
|
),
|
||||||
|
if (errorText != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 6),
|
||||||
|
child: Text(
|
||||||
|
errorText,
|
||||||
|
style: theme.textTheme.small.copyWith(
|
||||||
|
color: materialTheme.colorScheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ValueListenableBuilder<String?>(
|
||||||
|
valueListenable: emailError,
|
||||||
|
builder: (_, errorText, __) {
|
||||||
|
return _FormField(
|
||||||
|
label: '이메일',
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
ShadInput(
|
||||||
|
key: const ValueKey('user_form_email'),
|
||||||
|
controller: emailController,
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
onChanged: (_) {
|
||||||
|
if (emailController.text.trim().isNotEmpty) {
|
||||||
|
emailError.value = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (errorText != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 6),
|
||||||
|
child: Text(
|
||||||
|
errorText,
|
||||||
|
style: theme.textTheme.small.copyWith(
|
||||||
|
color: materialTheme.colorScheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_FormField(
|
ValueListenableBuilder<String?>(
|
||||||
label: '연락처',
|
valueListenable: phoneError,
|
||||||
child: ShadInput(
|
builder: (_, errorText, __) {
|
||||||
controller: mobileController,
|
return _FormField(
|
||||||
keyboardType: TextInputType.phone,
|
label: '연락처',
|
||||||
),
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
ShadInput(
|
||||||
|
key: const ValueKey('user_form_phone'),
|
||||||
|
controller: mobileController,
|
||||||
|
keyboardType: TextInputType.phone,
|
||||||
|
onChanged: (_) {
|
||||||
|
if (mobileController.text.trim().isNotEmpty) {
|
||||||
|
phoneError.value = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (errorText != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 6),
|
||||||
|
child: Text(
|
||||||
|
errorText,
|
||||||
|
style: theme.textTheme.small.copyWith(
|
||||||
|
color: materialTheme.colorScheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
ValueListenableBuilder<int?>(
|
ValueListenableBuilder<int?>(
|
||||||
@@ -638,13 +766,17 @@ class _UserEnabledPageState extends State<_UserEnabledPage> {
|
|||||||
nameController.dispose();
|
nameController.dispose();
|
||||||
emailController.dispose();
|
emailController.dispose();
|
||||||
mobileController.dispose();
|
mobileController.dispose();
|
||||||
|
passwordController.dispose();
|
||||||
noteController.dispose();
|
noteController.dispose();
|
||||||
groupNotifier.dispose();
|
groupNotifier.dispose();
|
||||||
isActiveNotifier.dispose();
|
isActiveNotifier.dispose();
|
||||||
saving.dispose();
|
saving.dispose();
|
||||||
codeError.dispose();
|
codeError.dispose();
|
||||||
nameError.dispose();
|
nameError.dispose();
|
||||||
|
emailError.dispose();
|
||||||
|
phoneError.dispose();
|
||||||
groupError.dispose();
|
groupError.dispose();
|
||||||
|
passwordError.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _confirmDelete(UserAccount user) async {
|
Future<void> _confirmDelete(UserAccount user) async {
|
||||||
@@ -684,6 +816,47 @@ class _UserEnabledPageState extends State<_UserEnabledPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmResetPassword(UserAccount user) async {
|
||||||
|
final userId = user.id;
|
||||||
|
if (userId == null) {
|
||||||
|
_showSnack('ID 정보가 없어 비밀번호를 재설정할 수 없습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final confirmed = await SuperportDialog.show<bool>(
|
||||||
|
context: context,
|
||||||
|
dialog: SuperportDialog(
|
||||||
|
title: '비밀번호 재설정',
|
||||||
|
description:
|
||||||
|
'"${user.employeeName}" 사용자의 비밀번호를 재설정하고 임시 비밀번호를 이메일로 발송합니다.',
|
||||||
|
actions: [
|
||||||
|
ShadButton.ghost(
|
||||||
|
onPressed: () =>
|
||||||
|
Navigator.of(context, rootNavigator: true).pop(false),
|
||||||
|
child: const Text('취소'),
|
||||||
|
),
|
||||||
|
ShadButton(
|
||||||
|
onPressed: () =>
|
||||||
|
Navigator.of(context, rootNavigator: true).pop(true),
|
||||||
|
child: const Text('재설정'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed == true) {
|
||||||
|
final updated = await _controller.resetPassword(userId);
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (updated != null) {
|
||||||
|
_showSnack('임시 비밀번호를 이메일로 발송했습니다.');
|
||||||
|
} else if (_controller.errorMessage != null) {
|
||||||
|
_showSnack(_controller.errorMessage!);
|
||||||
|
_controller.clearError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _showSnack(String message) {
|
void _showSnack(String message) {
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return;
|
return;
|
||||||
@@ -695,6 +868,33 @@ class _UserEnabledPageState extends State<_UserEnabledPage> {
|
|||||||
messenger.showSnackBar(SnackBar(content: Text(message)));
|
messenger.showSnackBar(SnackBar(content: Text(message)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _describePasswordViolations(List<PasswordRuleViolation> violations) {
|
||||||
|
final messages = <String>[];
|
||||||
|
for (final violation in violations) {
|
||||||
|
switch (violation) {
|
||||||
|
case PasswordRuleViolation.tooShort:
|
||||||
|
messages.add('최소 8자 이상 입력해야 합니다.');
|
||||||
|
break;
|
||||||
|
case PasswordRuleViolation.tooLong:
|
||||||
|
messages.add('최대 24자 이하로 입력해야 합니다.');
|
||||||
|
break;
|
||||||
|
case PasswordRuleViolation.missingUppercase:
|
||||||
|
messages.add('대문자를 최소 1자 포함해야 합니다.');
|
||||||
|
break;
|
||||||
|
case PasswordRuleViolation.missingLowercase:
|
||||||
|
messages.add('소문자를 최소 1자 포함해야 합니다.');
|
||||||
|
break;
|
||||||
|
case PasswordRuleViolation.missingDigit:
|
||||||
|
messages.add('숫자를 최소 1자 포함해야 합니다.');
|
||||||
|
break;
|
||||||
|
case PasswordRuleViolation.missingSpecial:
|
||||||
|
messages.add('특수문자를 최소 1자 포함해야 합니다.');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return messages.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
List<Widget> _buildAuditInfo(UserAccount user, ShadThemeData theme) {
|
List<Widget> _buildAuditInfo(UserAccount user, ShadThemeData theme) {
|
||||||
return [
|
return [
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
@@ -722,12 +922,14 @@ class _UserTable extends StatelessWidget {
|
|||||||
required this.onEdit,
|
required this.onEdit,
|
||||||
required this.onDelete,
|
required this.onDelete,
|
||||||
required this.onRestore,
|
required this.onRestore,
|
||||||
|
required this.onResetPassword,
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<UserAccount> users;
|
final List<UserAccount> users;
|
||||||
final void Function(UserAccount user)? onEdit;
|
final void Function(UserAccount user)? onEdit;
|
||||||
final void Function(UserAccount user)? onDelete;
|
final void Function(UserAccount user)? onDelete;
|
||||||
final void Function(UserAccount user)? onRestore;
|
final void Function(UserAccount user)? onRestore;
|
||||||
|
final void Function(UserAccount user)? onResetPassword;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -761,31 +963,40 @@ class _UserTable extends StatelessWidget {
|
|||||||
: user.updatedAt!.toLocal().toIso8601String(),
|
: user.updatedAt!.toLocal().toIso8601String(),
|
||||||
].map((text) => ShadTableCell(child: Text(text))).toList()..add(
|
].map((text) => ShadTableCell(child: Text(text))).toList()..add(
|
||||||
ShadTableCell(
|
ShadTableCell(
|
||||||
child: Row(
|
child: Align(
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
alignment: Alignment.centerRight,
|
||||||
children: [
|
child: Wrap(
|
||||||
ShadButton.ghost(
|
spacing: 8,
|
||||||
size: ShadButtonSize.sm,
|
children: [
|
||||||
onPressed: onEdit == null ? null : () => onEdit!(user),
|
ShadButton.ghost(
|
||||||
child: const Icon(LucideIcons.pencil, size: 16),
|
size: ShadButtonSize.sm,
|
||||||
),
|
onPressed: onResetPassword == null
|
||||||
const SizedBox(width: 8),
|
? null
|
||||||
user.isDeleted
|
: () => onResetPassword!(user),
|
||||||
? ShadButton.ghost(
|
child: const Icon(LucideIcons.refreshCcw, size: 16),
|
||||||
size: ShadButtonSize.sm,
|
),
|
||||||
onPressed: onRestore == null
|
ShadButton.ghost(
|
||||||
? null
|
size: ShadButtonSize.sm,
|
||||||
: () => onRestore!(user),
|
onPressed: onEdit == null ? null : () => onEdit!(user),
|
||||||
child: const Icon(LucideIcons.history, size: 16),
|
child: const Icon(LucideIcons.pencil, size: 16),
|
||||||
)
|
),
|
||||||
: ShadButton.ghost(
|
user.isDeleted
|
||||||
size: ShadButtonSize.sm,
|
? ShadButton.ghost(
|
||||||
onPressed: onDelete == null
|
size: ShadButtonSize.sm,
|
||||||
? null
|
onPressed: onRestore == null
|
||||||
: () => onDelete!(user),
|
? null
|
||||||
child: const Icon(LucideIcons.trash2, size: 16),
|
: () => onRestore!(user),
|
||||||
),
|
child: const Icon(LucideIcons.history, size: 16),
|
||||||
],
|
)
|
||||||
|
: ShadButton.ghost(
|
||||||
|
size: ShadButtonSize.sm,
|
||||||
|
onPressed: onDelete == null
|
||||||
|
? null
|
||||||
|
: () => onDelete!(user),
|
||||||
|
child: const Icon(LucideIcons.trash2, size: 16),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,13 +2,18 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
|
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
|
|
||||||
import '../core/constants/app_sections.dart';
|
import '../core/constants/app_sections.dart';
|
||||||
import '../core/permissions/permission_manager.dart';
|
import '../core/permissions/permission_manager.dart';
|
||||||
|
import '../core/network/failure.dart';
|
||||||
import '../core/theme/theme_controller.dart';
|
import '../core/theme/theme_controller.dart';
|
||||||
|
import '../core/validation/password_rules.dart';
|
||||||
import '../features/auth/application/auth_service.dart';
|
import '../features/auth/application/auth_service.dart';
|
||||||
import '../features/auth/domain/entities/auth_session.dart';
|
import '../features/auth/domain/entities/auth_session.dart';
|
||||||
|
import '../features/masters/user/domain/entities/user.dart';
|
||||||
|
import '../features/masters/user/domain/repositories/user_repository.dart';
|
||||||
import 'components/superport_dialog.dart';
|
import 'components/superport_dialog.dart';
|
||||||
|
|
||||||
/// 앱 기본 레이아웃을 제공하는 셸 위젯. 사이드 네비게이션과 AppBar를 구성한다.
|
/// 앱 기본 레이아웃을 제공하는 셸 위젯. 사이드 네비게이션과 AppBar를 구성한다.
|
||||||
@@ -411,60 +416,631 @@ class _AccountMenuButton extends StatelessWidget {
|
|||||||
return AnimatedBuilder(
|
return AnimatedBuilder(
|
||||||
animation: service,
|
animation: service,
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
final session = service.session;
|
|
||||||
return IconButton(
|
return IconButton(
|
||||||
tooltip: '계정 정보',
|
tooltip: '내 정보',
|
||||||
icon: const Icon(lucide.LucideIcons.userRound),
|
icon: const Icon(lucide.LucideIcons.userRound),
|
||||||
onPressed: () => _handlePressed(context, session),
|
onPressed: () => _handlePressed(context),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handlePressed(
|
Future<void> _handlePressed(BuildContext context) async {
|
||||||
BuildContext context,
|
final userRepository = GetIt.I<UserRepository>();
|
||||||
AuthSession? session,
|
final result = await showDialog<_AccountDialogResult>(
|
||||||
) async {
|
|
||||||
final shouldLogout = await SuperportDialog.show<bool>(
|
|
||||||
context: context,
|
context: context,
|
||||||
dialog: SuperportDialog(
|
barrierDismissible: false,
|
||||||
title: '계정 정보',
|
builder: (_) => _AccountDialog(
|
||||||
description: session == null
|
authService: service,
|
||||||
? '로그인 정보를 찾을 수 없습니다.'
|
userRepository: userRepository,
|
||||||
: '현재 로그인된 계정 세부 정보를 확인하세요.',
|
hostContext: context,
|
||||||
footer: Builder(
|
|
||||||
builder: (dialogContext) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
|
||||||
children: [
|
|
||||||
ShadButton.outline(
|
|
||||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
|
||||||
child: const Text('닫기'),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
ShadButton.destructive(
|
|
||||||
onPressed: session == null
|
|
||||||
? null
|
|
||||||
: () => Navigator.of(dialogContext).pop(true),
|
|
||||||
child: const Text('로그아웃'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
scrollable: session != null && session.permissions.length > 6,
|
|
||||||
child: _AccountInfoContent(session: session),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (shouldLogout == true) {
|
|
||||||
await service.clearSession();
|
switch (result) {
|
||||||
if (!context.mounted) return;
|
case _AccountDialogResult.logout:
|
||||||
context.go(loginRoutePath);
|
await service.clearSession();
|
||||||
|
if (context.mounted) {
|
||||||
|
context.go(loginRoutePath);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case _AccountDialogResult.passwordChanged:
|
||||||
|
final confirmed = await _showMandatoryLogoutDialog(context);
|
||||||
|
if (confirmed == true) {
|
||||||
|
await service.clearSession();
|
||||||
|
if (context.mounted) {
|
||||||
|
context.go(loginRoutePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case _AccountDialogResult.none:
|
||||||
|
case null:
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<bool?> _showMandatoryLogoutDialog(BuildContext context) {
|
||||||
|
return SuperportDialog.show<bool>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
dialog: SuperportDialog(
|
||||||
|
title: '비밀번호 변경 완료',
|
||||||
|
description: '비밀번호가 변경되었습니다. 다시 로그인해주세요.',
|
||||||
|
showCloseButton: false,
|
||||||
|
primaryAction: ShadButton(
|
||||||
|
onPressed: () => Navigator.of(context, rootNavigator: true).pop(true),
|
||||||
|
child: const Text('확인'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum _AccountDialogResult { none, logout, passwordChanged }
|
||||||
|
|
||||||
|
class _AccountDialog extends StatefulWidget {
|
||||||
|
const _AccountDialog({
|
||||||
|
required this.authService,
|
||||||
|
required this.userRepository,
|
||||||
|
required this.hostContext,
|
||||||
|
});
|
||||||
|
|
||||||
|
final AuthService authService;
|
||||||
|
final UserRepository userRepository;
|
||||||
|
final BuildContext hostContext;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_AccountDialog> createState() => _AccountDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AccountDialogState extends State<_AccountDialog> {
|
||||||
|
static final RegExp _emailRegExp = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$');
|
||||||
|
static final RegExp _phoneRegExp = RegExp(r'^[0-9+\-\s]{7,}$');
|
||||||
|
|
||||||
|
late final TextEditingController _emailController;
|
||||||
|
late final TextEditingController _phoneController;
|
||||||
|
String? _emailError;
|
||||||
|
String? _phoneError;
|
||||||
|
String? _generalError;
|
||||||
|
bool _isSaving = false;
|
||||||
|
late String _initialEmail;
|
||||||
|
late String _initialPhone;
|
||||||
|
|
||||||
|
AuthSession? get _session => widget.authService.session;
|
||||||
|
|
||||||
|
bool get _isDirty =>
|
||||||
|
_emailController.text.trim() != _initialEmail ||
|
||||||
|
_phoneController.text.trim() != _initialPhone;
|
||||||
|
|
||||||
|
bool get _canEdit => _session != null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final session = _session;
|
||||||
|
_initialEmail = session?.user.email ?? '';
|
||||||
|
_initialPhone = session?.user.phone ?? '';
|
||||||
|
_emailController = TextEditingController(text: _initialEmail)
|
||||||
|
..addListener(_handleChanged);
|
||||||
|
_phoneController = TextEditingController(text: _initialPhone)
|
||||||
|
..addListener(_handleChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_emailController.dispose();
|
||||||
|
_phoneController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleChanged() {
|
||||||
|
if ((_emailError != null || _phoneError != null) && mounted) {
|
||||||
|
setState(() {
|
||||||
|
_emailError = null;
|
||||||
|
_phoneError = null;
|
||||||
|
_generalError = null;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final session = _session;
|
||||||
|
return _wrapWithWillPop(
|
||||||
|
SuperportDialog(
|
||||||
|
title: '내 정보',
|
||||||
|
description: session == null
|
||||||
|
? '로그인 정보를 찾을 수 없습니다.'
|
||||||
|
: '${session.user.name}님의 계정 정보를 확인하고 수정하세요.',
|
||||||
|
scrollable: true,
|
||||||
|
showCloseButton: false,
|
||||||
|
footer: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
ShadButton.outline(
|
||||||
|
onPressed: _isSaving
|
||||||
|
? null
|
||||||
|
: () => Navigator.of(
|
||||||
|
context,
|
||||||
|
rootNavigator: true,
|
||||||
|
).pop(_AccountDialogResult.none),
|
||||||
|
child: const Text('닫기'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
ShadButton.destructive(
|
||||||
|
onPressed: !_canEdit || _isSaving
|
||||||
|
? null
|
||||||
|
: () => Navigator.of(
|
||||||
|
context,
|
||||||
|
rootNavigator: true,
|
||||||
|
).pop(_AccountDialogResult.logout),
|
||||||
|
child: const Text('로그아웃'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: _AccountDialogBody(
|
||||||
|
session: session,
|
||||||
|
emailController: _emailController,
|
||||||
|
phoneController: _phoneController,
|
||||||
|
emailError: _emailError,
|
||||||
|
phoneError: _phoneError,
|
||||||
|
generalError: _generalError,
|
||||||
|
isSaving: _isSaving,
|
||||||
|
canEdit: _canEdit,
|
||||||
|
onSave: _saveProfile,
|
||||||
|
onPasswordChange: _handlePasswordChange,
|
||||||
|
canSave: _isDirty && !_isSaving && _canEdit,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _wrapWithWillPop(Widget child) {
|
||||||
|
return WillPopScope(onWillPop: () async => !_isSaving, child: child);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveProfile() async {
|
||||||
|
if (!_canEdit || _isSaving) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final email = _emailController.text.trim();
|
||||||
|
final phone = _phoneController.text.trim();
|
||||||
|
var hasError = false;
|
||||||
|
|
||||||
|
if (email.isEmpty || !_emailRegExp.hasMatch(email)) {
|
||||||
|
_emailError = '올바른 이메일 주소를 입력하세요.';
|
||||||
|
hasError = true;
|
||||||
|
}
|
||||||
|
if (phone.isEmpty || !_phoneRegExp.hasMatch(phone)) {
|
||||||
|
_phoneError = '연락처는 숫자/+, -/공백만 사용해 7자 이상 입력하세요.';
|
||||||
|
hasError = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasError) {
|
||||||
|
setState(() {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isSaving = true;
|
||||||
|
_generalError = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final result = await widget.userRepository.updateMe(
|
||||||
|
UserProfileUpdateInput(email: email, phone: phone),
|
||||||
|
);
|
||||||
|
final updatedEmail = result.email ?? email;
|
||||||
|
final updatedPhone = result.mobileNo ?? phone;
|
||||||
|
|
||||||
|
_initialEmail = updatedEmail;
|
||||||
|
_initialPhone = updatedPhone;
|
||||||
|
|
||||||
|
if (_emailController.text != updatedEmail) {
|
||||||
|
_emailController.text = updatedEmail;
|
||||||
|
}
|
||||||
|
if (_phoneController.text != updatedPhone) {
|
||||||
|
_phoneController.text = updatedPhone;
|
||||||
|
}
|
||||||
|
|
||||||
|
final session = _session;
|
||||||
|
if (session != null) {
|
||||||
|
final updatedUser = session.user.copyWith(
|
||||||
|
email: updatedEmail,
|
||||||
|
phone: updatedPhone,
|
||||||
|
name: result.employeeName,
|
||||||
|
employeeNo: result.employeeNo,
|
||||||
|
);
|
||||||
|
widget.authService.updateSessionUser(updatedUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isSaving = false;
|
||||||
|
_emailError = null;
|
||||||
|
_phoneError = null;
|
||||||
|
});
|
||||||
|
_showSnack('프로필 정보를 저장했습니다.');
|
||||||
|
} catch (error) {
|
||||||
|
final failure = Failure.from(error);
|
||||||
|
setState(() {
|
||||||
|
_isSaving = false;
|
||||||
|
_generalError = failure.describe();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handlePasswordChange() async {
|
||||||
|
if (!_canEdit || _isSaving) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final changed = await _PasswordChangeDialog.show(
|
||||||
|
context: context,
|
||||||
|
userRepository: widget.userRepository,
|
||||||
|
);
|
||||||
|
if (changed == true && mounted) {
|
||||||
|
Navigator.of(
|
||||||
|
context,
|
||||||
|
rootNavigator: true,
|
||||||
|
).pop(_AccountDialogResult.passwordChanged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showSnack(String message) {
|
||||||
|
final messenger = ScaffoldMessenger.maybeOf(widget.hostContext);
|
||||||
|
messenger?.showSnackBar(SnackBar(content: Text(message)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AccountDialogBody extends StatelessWidget {
|
||||||
|
const _AccountDialogBody({
|
||||||
|
required this.session,
|
||||||
|
required this.emailController,
|
||||||
|
required this.phoneController,
|
||||||
|
required this.emailError,
|
||||||
|
required this.phoneError,
|
||||||
|
required this.generalError,
|
||||||
|
required this.isSaving,
|
||||||
|
required this.canEdit,
|
||||||
|
required this.onSave,
|
||||||
|
required this.onPasswordChange,
|
||||||
|
required this.canSave,
|
||||||
|
});
|
||||||
|
|
||||||
|
final AuthSession? session;
|
||||||
|
final TextEditingController emailController;
|
||||||
|
final TextEditingController phoneController;
|
||||||
|
final String? emailError;
|
||||||
|
final String? phoneError;
|
||||||
|
final String? generalError;
|
||||||
|
final bool isSaving;
|
||||||
|
final bool canEdit;
|
||||||
|
final bool canSave;
|
||||||
|
final VoidCallback onSave;
|
||||||
|
final VoidCallback onPasswordChange;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_AccountInfoContent(session: session),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Divider(color: Theme.of(context).colorScheme.outlineVariant),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text('연락처 / 이메일 수정', style: Theme.of(context).textTheme.titleSmall),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_LabeledField(
|
||||||
|
label: '이메일',
|
||||||
|
controller: emailController,
|
||||||
|
fieldKey: const ValueKey('account_email_field'),
|
||||||
|
enabled: canEdit && !isSaving,
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
errorText: emailError,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_LabeledField(
|
||||||
|
label: '연락처',
|
||||||
|
controller: phoneController,
|
||||||
|
fieldKey: const ValueKey('account_phone_field'),
|
||||||
|
enabled: canEdit && !isSaving,
|
||||||
|
keyboardType: TextInputType.phone,
|
||||||
|
errorText: phoneError,
|
||||||
|
),
|
||||||
|
if (generalError != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
generalError!,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: ShadButton(
|
||||||
|
onPressed: canSave ? onSave : null,
|
||||||
|
child: isSaving
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Text('저장'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Divider(color: Theme.of(context).colorScheme.outlineVariant),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text('보안', style: Theme.of(context).textTheme.titleSmall),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
ShadButton.outline(
|
||||||
|
onPressed: isSaving ? null : onPasswordChange,
|
||||||
|
child: const Text('비밀번호 변경'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LabeledField extends StatelessWidget {
|
||||||
|
const _LabeledField({
|
||||||
|
required this.label,
|
||||||
|
required this.controller,
|
||||||
|
this.fieldKey,
|
||||||
|
this.enabled = true,
|
||||||
|
this.keyboardType,
|
||||||
|
this.errorText,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final TextEditingController controller;
|
||||||
|
final Key? fieldKey;
|
||||||
|
final bool enabled;
|
||||||
|
final TextInputType? keyboardType;
|
||||||
|
final String? errorText;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = ShadTheme.of(context);
|
||||||
|
final materialTheme = Theme.of(context);
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(label, style: theme.textTheme.small),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
ShadInput(
|
||||||
|
key: fieldKey,
|
||||||
|
controller: controller,
|
||||||
|
enabled: enabled,
|
||||||
|
keyboardType: keyboardType,
|
||||||
|
),
|
||||||
|
if (errorText != null) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
errorText!,
|
||||||
|
style: theme.textTheme.small.copyWith(
|
||||||
|
color: materialTheme.colorScheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PasswordChangeDialog extends StatefulWidget {
|
||||||
|
const _PasswordChangeDialog({required this.userRepository});
|
||||||
|
|
||||||
|
final UserRepository userRepository;
|
||||||
|
|
||||||
|
static Future<bool?> show({
|
||||||
|
required BuildContext context,
|
||||||
|
required UserRepository userRepository,
|
||||||
|
}) {
|
||||||
|
return showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (_) => _PasswordChangeDialog(userRepository: userRepository),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_PasswordChangeDialog> createState() => _PasswordChangeDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PasswordChangeDialogState extends State<_PasswordChangeDialog> {
|
||||||
|
final TextEditingController _currentController = TextEditingController();
|
||||||
|
final TextEditingController _newController = TextEditingController();
|
||||||
|
final TextEditingController _confirmController = TextEditingController();
|
||||||
|
|
||||||
|
String? _currentError;
|
||||||
|
String? _newError;
|
||||||
|
String? _confirmError;
|
||||||
|
String? _generalError;
|
||||||
|
bool _isSaving = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_currentController.dispose();
|
||||||
|
_newController.dispose();
|
||||||
|
_confirmController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SuperportDialog(
|
||||||
|
title: '비밀번호 변경',
|
||||||
|
showCloseButton: !_isSaving,
|
||||||
|
primaryAction: ShadButton(
|
||||||
|
onPressed: _isSaving ? null : _handleSubmit,
|
||||||
|
child: _isSaving
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Text('변경'),
|
||||||
|
),
|
||||||
|
secondaryAction: ShadButton.outline(
|
||||||
|
onPressed: _isSaving
|
||||||
|
? null
|
||||||
|
: () => Navigator.of(context, rootNavigator: true).pop(false),
|
||||||
|
child: const Text('취소'),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_PasswordField(
|
||||||
|
label: '현재 비밀번호',
|
||||||
|
controller: _currentController,
|
||||||
|
fieldKey: const ValueKey('account_current_password'),
|
||||||
|
errorText: _currentError,
|
||||||
|
enabled: !_isSaving,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_PasswordField(
|
||||||
|
label: '새 비밀번호',
|
||||||
|
controller: _newController,
|
||||||
|
fieldKey: const ValueKey('account_new_password'),
|
||||||
|
errorText: _newError,
|
||||||
|
enabled: !_isSaving,
|
||||||
|
helper: '8~24자, 대문자/소문자/숫자/특수문자 각 1자 이상 포함',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_PasswordField(
|
||||||
|
label: '새 비밀번호 확인',
|
||||||
|
controller: _confirmController,
|
||||||
|
fieldKey: const ValueKey('account_confirm_password'),
|
||||||
|
errorText: _confirmError,
|
||||||
|
enabled: !_isSaving,
|
||||||
|
),
|
||||||
|
if (_generalError != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
_generalError!,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleSubmit() async {
|
||||||
|
if (_isSaving) return;
|
||||||
|
final current = _currentController.text.trim();
|
||||||
|
final next = _newController.text.trim();
|
||||||
|
final confirm = _confirmController.text.trim();
|
||||||
|
|
||||||
|
var hasError = false;
|
||||||
|
if (current.isEmpty) {
|
||||||
|
_currentError = '현재 비밀번호를 입력하세요.';
|
||||||
|
hasError = true;
|
||||||
|
} else {
|
||||||
|
_currentError = null;
|
||||||
|
}
|
||||||
|
if (!PasswordRules.isValid(next)) {
|
||||||
|
_newError = '비밀번호 정책을 만족하도록 입력하세요.';
|
||||||
|
hasError = true;
|
||||||
|
} else {
|
||||||
|
_newError = null;
|
||||||
|
}
|
||||||
|
if (next != confirm) {
|
||||||
|
_confirmError = '새 비밀번호가 일치하지 않습니다.';
|
||||||
|
hasError = true;
|
||||||
|
} else {
|
||||||
|
_confirmError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasError) {
|
||||||
|
setState(() {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isSaving = true;
|
||||||
|
_generalError = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await widget.userRepository.updateMe(
|
||||||
|
UserProfileUpdateInput(password: next, currentPassword: current),
|
||||||
|
);
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.of(context, rootNavigator: true).pop(true);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
final failure = Failure.from(error);
|
||||||
|
setState(() {
|
||||||
|
_generalError = failure.describe();
|
||||||
|
_isSaving = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PasswordField extends StatelessWidget {
|
||||||
|
const _PasswordField({
|
||||||
|
required this.label,
|
||||||
|
required this.controller,
|
||||||
|
this.helper,
|
||||||
|
this.errorText,
|
||||||
|
this.enabled = true,
|
||||||
|
this.fieldKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final TextEditingController controller;
|
||||||
|
final String? helper;
|
||||||
|
final String? errorText;
|
||||||
|
final bool enabled;
|
||||||
|
final Key? fieldKey;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = ShadTheme.of(context);
|
||||||
|
final materialTheme = Theme.of(context);
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(label, style: theme.textTheme.small),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
ShadInput(
|
||||||
|
key: fieldKey,
|
||||||
|
controller: controller,
|
||||||
|
enabled: enabled,
|
||||||
|
obscureText: true,
|
||||||
|
),
|
||||||
|
if (helper != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 6),
|
||||||
|
child: Text(helper!, style: theme.textTheme.muted),
|
||||||
|
),
|
||||||
|
if (errorText != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 6),
|
||||||
|
child: Text(
|
||||||
|
errorText!,
|
||||||
|
style: theme.textTheme.small.copyWith(
|
||||||
|
color: materialTheme.colorScheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 로그인된 계정의 핵심 정보를 보여주는 다이얼로그 본문.
|
/// 로그인된 계정의 핵심 정보를 보여주는 다이얼로그 본문.
|
||||||
@@ -499,6 +1075,7 @@ class _AccountInfoContent extends StatelessWidget {
|
|||||||
_AccountInfoRow(label: '이름', value: user.name),
|
_AccountInfoRow(label: '이름', value: user.name),
|
||||||
_AccountInfoRow(label: '사번', value: user.employeeNo ?? '-'),
|
_AccountInfoRow(label: '사번', value: user.employeeNo ?? '-'),
|
||||||
_AccountInfoRow(label: '이메일', value: user.email ?? '-'),
|
_AccountInfoRow(label: '이메일', value: user.email ?? '-'),
|
||||||
|
_AccountInfoRow(label: '연락처', value: user.phone ?? '-'),
|
||||||
_AccountInfoRow(label: '기본 그룹', value: user.primaryGroupName ?? '-'),
|
_AccountInfoRow(label: '기본 그룹', value: user.primaryGroupName ?? '-'),
|
||||||
_AccountInfoRow(label: '토큰 만료', value: expiryLabel),
|
_AccountInfoRow(label: '토큰 만료', value: expiryLabel),
|
||||||
_AccountInfoRow(
|
_AccountInfoRow(
|
||||||
|
|||||||
57
test/core/validation/password_rules_test.dart
Normal file
57
test/core/validation/password_rules_test.dart
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:superport_v2/core/validation/password_rules.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('PasswordRules', () {
|
||||||
|
test('정책을 모두 만족하면 위반 목록이 비어 있다', () {
|
||||||
|
const password = 'Aa1!abcd';
|
||||||
|
|
||||||
|
final result = PasswordRules.validate(password);
|
||||||
|
|
||||||
|
expect(result, isEmpty);
|
||||||
|
expect(PasswordRules.isValid(password), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('길이가 짧으면 tooShort 위반이 발생한다', () {
|
||||||
|
final result = PasswordRules.validate('Aa1!');
|
||||||
|
|
||||||
|
expect(result, contains(PasswordRuleViolation.tooShort));
|
||||||
|
expect(result, isNot(contains(PasswordRuleViolation.tooLong)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('길이가 길면 tooLong 위반이 발생한다', () {
|
||||||
|
const password =
|
||||||
|
'Aa1!'
|
||||||
|
'abcdefghijklmnopqrstu';
|
||||||
|
|
||||||
|
final result = PasswordRules.validate(password);
|
||||||
|
|
||||||
|
expect(result, contains(PasswordRuleViolation.tooLong));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('대문자가 없으면 missingUppercase 위반이 발생한다', () {
|
||||||
|
final result = PasswordRules.validate('aa1!aaaa');
|
||||||
|
|
||||||
|
expect(result, contains(PasswordRuleViolation.missingUppercase));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('소문자가 없으면 missingLowercase 위반이 발생한다', () {
|
||||||
|
final result = PasswordRules.validate('AA1!AAAA');
|
||||||
|
|
||||||
|
expect(result, contains(PasswordRuleViolation.missingLowercase));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('숫자가 없으면 missingDigit 위반이 발생한다', () {
|
||||||
|
final result = PasswordRules.validate('AAa!aaaa');
|
||||||
|
|
||||||
|
expect(result, contains(PasswordRuleViolation.missingDigit));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('특수 문자가 없으면 missingSpecial 위반이 발생한다', () {
|
||||||
|
final result = PasswordRules.validate('AAa1aaaa');
|
||||||
|
|
||||||
|
expect(result, contains(PasswordRuleViolation.missingSpecial));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -80,7 +80,7 @@ void main() {
|
|||||||
accessToken: 'access-token',
|
accessToken: 'access-token',
|
||||||
refreshToken: 'refresh-token',
|
refreshToken: 'refresh-token',
|
||||||
expiresAt: DateTime.now().add(const Duration(hours: 1)),
|
expiresAt: DateTime.now().add(const Duration(hours: 1)),
|
||||||
user: const AuthenticatedUser(id: 1, name: '테스터'),
|
user: const AuthenticatedUser(id: 1, name: '테스터', phone: null),
|
||||||
permissions: const [],
|
permissions: const [],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:mocktail/mocktail.dart';
|
|||||||
|
|
||||||
import 'package:superport_v2/core/network/api_client.dart';
|
import 'package:superport_v2/core/network/api_client.dart';
|
||||||
import 'package:superport_v2/features/masters/user/data/repositories/user_repository_remote.dart';
|
import 'package:superport_v2/features/masters/user/data/repositories/user_repository_remote.dart';
|
||||||
|
import 'package:superport_v2/features/masters/user/domain/entities/user.dart';
|
||||||
|
|
||||||
class _MockApiClient extends Mock implements ApiClient {}
|
class _MockApiClient extends Mock implements ApiClient {}
|
||||||
|
|
||||||
@@ -14,6 +15,9 @@ void main() {
|
|||||||
setUpAll(() {
|
setUpAll(() {
|
||||||
registerFallbackValue(Options());
|
registerFallbackValue(Options());
|
||||||
registerFallbackValue(CancelToken());
|
registerFallbackValue(CancelToken());
|
||||||
|
registerFallbackValue(
|
||||||
|
Response<dynamic>(requestOptions: RequestOptions(path: '/fallback')),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
setUp(() {
|
setUp(() {
|
||||||
@@ -35,13 +39,15 @@ void main() {
|
|||||||
'items': [
|
'items': [
|
||||||
{
|
{
|
||||||
'id': 1,
|
'id': 1,
|
||||||
'employee_no': 'E-001',
|
'employee_id': 'E-001',
|
||||||
'employee_name': '홍길동',
|
'name': '홍길동',
|
||||||
'group': {'id': 2, 'group_name': '관리자'},
|
'group': {'id': 2, 'group_name': '관리자'},
|
||||||
|
'force_password_change': false,
|
||||||
|
'password_updated_at': '2025-01-10T09:00:00Z',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
requestOptions: RequestOptions(path: '/api/v1/employees'),
|
requestOptions: RequestOptions(path: '/api/v1/users'),
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -59,7 +65,150 @@ void main() {
|
|||||||
final path = captured[0] as String;
|
final path = captured[0] as String;
|
||||||
final query = captured[1] as Map<String, dynamic>;
|
final query = captured[1] as Map<String, dynamic>;
|
||||||
|
|
||||||
expect(path, equals('/api/v1/employees'));
|
expect(path, equals('/api/v1/users'));
|
||||||
expect(query['include'], 'group');
|
expect(query['include'], 'group');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('create 호출 시 employee_id 파라미터를 전달한다', () async {
|
||||||
|
final response = Response<Map<String, dynamic>>(
|
||||||
|
data: {
|
||||||
|
'data': {
|
||||||
|
'id': 10,
|
||||||
|
'employee_id': 'E2025001',
|
||||||
|
'name': '김승인',
|
||||||
|
'email': 'approver@example.com',
|
||||||
|
'phone': '+82-10-1111-2222',
|
||||||
|
'group': {'id': 1, 'group_name': '관리자'},
|
||||||
|
'force_password_change': true,
|
||||||
|
'is_active': true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
requestOptions: RequestOptions(path: '/api/v1/users'),
|
||||||
|
statusCode: 201,
|
||||||
|
);
|
||||||
|
|
||||||
|
when(
|
||||||
|
() => apiClient.post<Map<String, dynamic>>(
|
||||||
|
any(),
|
||||||
|
data: any(named: 'data'),
|
||||||
|
options: any(named: 'options'),
|
||||||
|
cancelToken: any(named: 'cancelToken'),
|
||||||
|
),
|
||||||
|
).thenAnswer((_) async => response);
|
||||||
|
when(
|
||||||
|
() => apiClient.unwrapAsMap(response),
|
||||||
|
).thenReturn(response.data!['data'] as Map<String, dynamic>);
|
||||||
|
|
||||||
|
final input = UserInput(
|
||||||
|
employeeNo: 'E2025001',
|
||||||
|
employeeName: '김승인',
|
||||||
|
groupId: 1,
|
||||||
|
email: 'approver@example.com',
|
||||||
|
mobileNo: '+82-10-1111-2222',
|
||||||
|
password: 'TempPass!1',
|
||||||
|
);
|
||||||
|
|
||||||
|
final user = await repository.create(input);
|
||||||
|
|
||||||
|
expect(user.employeeNo, 'E2025001');
|
||||||
|
expect(user.forcePasswordChange, isTrue);
|
||||||
|
|
||||||
|
verify(
|
||||||
|
() => apiClient.post<Map<String, dynamic>>(
|
||||||
|
'/api/v1/users',
|
||||||
|
data: any(named: 'data'),
|
||||||
|
options: any(named: 'options'),
|
||||||
|
cancelToken: any(named: 'cancelToken'),
|
||||||
|
),
|
||||||
|
).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateMe 호출 시 /users/me 엔드포인트를 사용한다', () async {
|
||||||
|
final response = Response<Map<String, dynamic>>(
|
||||||
|
data: {
|
||||||
|
'data': {
|
||||||
|
'id': 7,
|
||||||
|
'employee_id': 'E2025001',
|
||||||
|
'name': '김승인',
|
||||||
|
'email': 'approver@example.com',
|
||||||
|
'phone': '+82-10-1111-2222',
|
||||||
|
'force_password_change': false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
requestOptions: RequestOptions(path: '/api/v1/users/me'),
|
||||||
|
statusCode: 200,
|
||||||
|
);
|
||||||
|
|
||||||
|
when(
|
||||||
|
() => apiClient.patch<Map<String, dynamic>>(
|
||||||
|
any(),
|
||||||
|
data: any(named: 'data'),
|
||||||
|
options: any(named: 'options'),
|
||||||
|
cancelToken: any(named: 'cancelToken'),
|
||||||
|
),
|
||||||
|
).thenAnswer((_) async => response);
|
||||||
|
when(
|
||||||
|
() => apiClient.unwrapAsMap(response),
|
||||||
|
).thenReturn(response.data!['data'] as Map<String, dynamic>);
|
||||||
|
|
||||||
|
final payload = UserProfileUpdateInput(
|
||||||
|
email: 'approver@example.com',
|
||||||
|
phone: '+82-10-1111-2222',
|
||||||
|
password: 'NewPass!23',
|
||||||
|
currentPassword: 'TempPass!1',
|
||||||
|
);
|
||||||
|
|
||||||
|
final user = await repository.updateMe(payload);
|
||||||
|
|
||||||
|
expect(user.email, 'approver@example.com');
|
||||||
|
verify(
|
||||||
|
() => apiClient.patch<Map<String, dynamic>>(
|
||||||
|
'/api/v1/users/me',
|
||||||
|
data: payload.toPayload(),
|
||||||
|
options: any(named: 'options'),
|
||||||
|
cancelToken: any(named: 'cancelToken'),
|
||||||
|
),
|
||||||
|
).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resetPassword 호출 시 /users/{id}/reset-password 엔드포인트를 사용한다', () async {
|
||||||
|
final response = Response<Map<String, dynamic>>(
|
||||||
|
data: {
|
||||||
|
'data': {
|
||||||
|
'id': 7,
|
||||||
|
'employee_id': 'E2025001',
|
||||||
|
'email': 'approver@example.com',
|
||||||
|
'force_password_change': true,
|
||||||
|
'password_updated_at': '2025-03-11T02:05:00Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
requestOptions: RequestOptions(path: '/api/v1/users/7/reset-password'),
|
||||||
|
statusCode: 200,
|
||||||
|
);
|
||||||
|
|
||||||
|
when(
|
||||||
|
() => apiClient.post<Map<String, dynamic>>(
|
||||||
|
any(),
|
||||||
|
data: any(named: 'data'),
|
||||||
|
options: any(named: 'options'),
|
||||||
|
cancelToken: any(named: 'cancelToken'),
|
||||||
|
),
|
||||||
|
).thenAnswer((_) async => response);
|
||||||
|
when(
|
||||||
|
() => apiClient.unwrapAsMap(response),
|
||||||
|
).thenReturn(response.data!['data'] as Map<String, dynamic>);
|
||||||
|
|
||||||
|
final user = await repository.resetPassword(7);
|
||||||
|
|
||||||
|
expect(user.forcePasswordChange, isTrue);
|
||||||
|
expect(user.passwordUpdatedAt, isNotNull);
|
||||||
|
verify(
|
||||||
|
() => apiClient.post<Map<String, dynamic>>(
|
||||||
|
'/api/v1/users/7/reset-password',
|
||||||
|
data: null,
|
||||||
|
options: any(named: 'options'),
|
||||||
|
cancelToken: any(named: 'cancelToken'),
|
||||||
|
),
|
||||||
|
).called(1);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -269,5 +269,16 @@ void main() {
|
|||||||
expect(restored, isNotNull);
|
expect(restored, isNotNull);
|
||||||
verify(() => userRepository.restore(1)).called(1);
|
verify(() => userRepository.restore(1)).called(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('resetPassword 성공', () async {
|
||||||
|
when(
|
||||||
|
() => userRepository.resetPassword(any()),
|
||||||
|
).thenAnswer((_) async => sampleUser);
|
||||||
|
|
||||||
|
final result = await controller.resetPassword(1);
|
||||||
|
|
||||||
|
expect(result, isNotNull);
|
||||||
|
verify(() => userRepository.resetPassword(1)).called(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ void main() {
|
|||||||
|
|
||||||
testWidgets('신규 등록 성공', (tester) async {
|
testWidgets('신규 등록 성공', (tester) async {
|
||||||
final view = tester.view;
|
final view = tester.view;
|
||||||
view.physicalSize = const Size(1280, 800);
|
view.physicalSize = const Size(1600, 900);
|
||||||
view.devicePixelRatio = 1.0;
|
view.devicePixelRatio = 1.0;
|
||||||
addTearDown(() {
|
addTearDown(() {
|
||||||
view.resetPhysicalSize();
|
view.resetPhysicalSize();
|
||||||
@@ -260,13 +260,26 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
final dialog = find.byType(Dialog);
|
final dialog = find.byType(Dialog);
|
||||||
final editableTexts = find.descendant(
|
await tester.enterText(
|
||||||
of: dialog,
|
find.byKey(const ValueKey('user_form_employee')),
|
||||||
matching: find.byType(EditableText),
|
'A010',
|
||||||
|
);
|
||||||
|
await tester.enterText(
|
||||||
|
find.byKey(const ValueKey('user_form_name')),
|
||||||
|
'신규 사용자',
|
||||||
|
);
|
||||||
|
await tester.enterText(
|
||||||
|
find.byKey(const ValueKey('user_form_password')),
|
||||||
|
'Aa1!abcd',
|
||||||
|
);
|
||||||
|
await tester.enterText(
|
||||||
|
find.byKey(const ValueKey('user_form_email')),
|
||||||
|
'new@superport.com',
|
||||||
|
);
|
||||||
|
await tester.enterText(
|
||||||
|
find.byKey(const ValueKey('user_form_phone')),
|
||||||
|
'010-1111-2222',
|
||||||
);
|
);
|
||||||
|
|
||||||
await tester.enterText(editableTexts.at(0), 'A010');
|
|
||||||
await tester.enterText(editableTexts.at(1), '신규 사용자');
|
|
||||||
|
|
||||||
final selectFinder = find.descendant(
|
final selectFinder = find.descendant(
|
||||||
of: dialog,
|
of: dialog,
|
||||||
@@ -290,9 +303,149 @@ void main() {
|
|||||||
|
|
||||||
expect(capturedInput, isNotNull);
|
expect(capturedInput, isNotNull);
|
||||||
expect(capturedInput?.employeeNo, 'A010');
|
expect(capturedInput?.employeeNo, 'A010');
|
||||||
|
expect(capturedInput?.password, 'Aa1!abcd');
|
||||||
|
expect(capturedInput?.forcePasswordChange, isTrue);
|
||||||
|
expect(capturedInput?.email, 'new@superport.com');
|
||||||
|
expect(capturedInput?.mobileNo, '010-1111-2222');
|
||||||
expect(find.byType(Dialog), findsNothing);
|
expect(find.byType(Dialog), findsNothing);
|
||||||
expect(find.text('A010'), findsOneWidget);
|
expect(find.text('A010'), findsOneWidget);
|
||||||
verify(() => userRepository.create(any())).called(1);
|
verify(() => userRepository.create(any())).called(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('비밀번호 정책을 위반하면 에러가 노출되고 생성이 중단된다', (tester) async {
|
||||||
|
final view = tester.view;
|
||||||
|
view.physicalSize = const Size(1280, 800);
|
||||||
|
view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(() {
|
||||||
|
view.resetPhysicalSize();
|
||||||
|
view.resetDevicePixelRatio();
|
||||||
|
});
|
||||||
|
|
||||||
|
when(
|
||||||
|
() => userRepository.list(
|
||||||
|
page: any(named: 'page'),
|
||||||
|
pageSize: any(named: 'pageSize'),
|
||||||
|
query: any(named: 'query'),
|
||||||
|
groupId: any(named: 'groupId'),
|
||||||
|
isActive: any(named: 'isActive'),
|
||||||
|
),
|
||||||
|
).thenAnswer(
|
||||||
|
(_) async => PaginatedResult<UserAccount>(
|
||||||
|
items: const [],
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
total: 0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(_buildApp(const UserPage()));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('신규 등록'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final dialog = find.byType(Dialog);
|
||||||
|
await tester.enterText(
|
||||||
|
find.byKey(const ValueKey('user_form_employee')),
|
||||||
|
'A011',
|
||||||
|
);
|
||||||
|
await tester.enterText(
|
||||||
|
find.byKey(const ValueKey('user_form_name')),
|
||||||
|
'정책 위반',
|
||||||
|
);
|
||||||
|
await tester.enterText(
|
||||||
|
find.byKey(const ValueKey('user_form_password')),
|
||||||
|
'abc',
|
||||||
|
);
|
||||||
|
await tester.enterText(
|
||||||
|
find.byKey(const ValueKey('user_form_email')),
|
||||||
|
'invalid@superport.com',
|
||||||
|
);
|
||||||
|
await tester.enterText(
|
||||||
|
find.byKey(const ValueKey('user_form_phone')),
|
||||||
|
'010-3333-4444',
|
||||||
|
);
|
||||||
|
|
||||||
|
final selectFinder = find.descendant(
|
||||||
|
of: dialog,
|
||||||
|
matching: find.byType(ShadSelect<int?>),
|
||||||
|
);
|
||||||
|
final selectElement = tester.element(selectFinder);
|
||||||
|
final renderBox = selectElement.renderObject as RenderBox;
|
||||||
|
final globalCenter = renderBox.localToGlobal(
|
||||||
|
renderBox.size.center(Offset.zero),
|
||||||
|
);
|
||||||
|
await tester.tapAt(globalCenter);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
await tester.tap(find.text('관리자', skipOffstage: false).first);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('등록'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.textContaining('최소 8자 이상 입력해야 합니다.'), findsOneWidget);
|
||||||
|
verifyNever(() => userRepository.create(any()));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('비밀번호 재설정 버튼을 통해 확인 후 API 호출', (tester) async {
|
||||||
|
final view = tester.view;
|
||||||
|
view.physicalSize = const Size(1280, 800);
|
||||||
|
view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(() {
|
||||||
|
view.resetPhysicalSize();
|
||||||
|
view.resetDevicePixelRatio();
|
||||||
|
});
|
||||||
|
|
||||||
|
when(
|
||||||
|
() => userRepository.list(
|
||||||
|
page: any(named: 'page'),
|
||||||
|
pageSize: any(named: 'pageSize'),
|
||||||
|
query: any(named: 'query'),
|
||||||
|
groupId: any(named: 'groupId'),
|
||||||
|
isActive: any(named: 'isActive'),
|
||||||
|
),
|
||||||
|
).thenAnswer(
|
||||||
|
(_) async => PaginatedResult<UserAccount>(
|
||||||
|
items: [
|
||||||
|
UserAccount(
|
||||||
|
id: 1,
|
||||||
|
employeeNo: 'A001',
|
||||||
|
employeeName: '홍길동',
|
||||||
|
group: UserGroup(id: 1, groupName: '관리자'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
total: 1,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
when(() => userRepository.resetPassword(any())).thenAnswer(
|
||||||
|
(_) async => UserAccount(
|
||||||
|
id: 1,
|
||||||
|
employeeNo: 'A001',
|
||||||
|
employeeName: '홍길동',
|
||||||
|
group: UserGroup(id: 1, groupName: '관리자'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(_buildApp(const UserPage()));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final resetFinder = find
|
||||||
|
.widgetWithIcon(ShadButton, LucideIcons.refreshCcw)
|
||||||
|
.first;
|
||||||
|
final resetButton = tester.widget<ShadButton>(resetFinder);
|
||||||
|
resetButton.onPressed?.call();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byType(Dialog), findsOneWidget);
|
||||||
|
expect(find.text('재설정'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('재설정'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
verify(() => userRepository.resetPassword(1)).called(1);
|
||||||
|
expect(find.text('비밀번호 재설정'), findsNothing);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -263,7 +263,7 @@ AuthSession _buildSampleSession() {
|
|||||||
accessToken: 'access-token',
|
accessToken: 'access-token',
|
||||||
refreshToken: 'refresh-token',
|
refreshToken: 'refresh-token',
|
||||||
expiresAt: null,
|
expiresAt: null,
|
||||||
user: AuthenticatedUser(id: 1, name: '테스터'),
|
user: AuthenticatedUser(id: 1, name: '테스터', phone: null),
|
||||||
permissions: [],
|
permissions: [],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart';
|
|||||||
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
|
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
|
||||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
|
|
||||||
|
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
||||||
import 'package:superport_v2/core/constants/app_sections.dart';
|
import 'package:superport_v2/core/constants/app_sections.dart';
|
||||||
import 'package:superport_v2/core/permissions/permission_manager.dart';
|
import 'package:superport_v2/core/permissions/permission_manager.dart';
|
||||||
import 'package:superport_v2/core/theme/superport_shad_theme.dart';
|
import 'package:superport_v2/core/theme/superport_shad_theme.dart';
|
||||||
@@ -16,6 +17,8 @@ import 'package:superport_v2/features/auth/domain/entities/auth_session.dart';
|
|||||||
import 'package:superport_v2/features/auth/domain/entities/authenticated_user.dart';
|
import 'package:superport_v2/features/auth/domain/entities/authenticated_user.dart';
|
||||||
import 'package:superport_v2/features/auth/domain/entities/login_request.dart';
|
import 'package:superport_v2/features/auth/domain/entities/login_request.dart';
|
||||||
import 'package:superport_v2/features/auth/domain/repositories/auth_repository.dart';
|
import 'package:superport_v2/features/auth/domain/repositories/auth_repository.dart';
|
||||||
|
import 'package:superport_v2/features/masters/user/domain/entities/user.dart';
|
||||||
|
import 'package:superport_v2/features/masters/user/domain/repositories/user_repository.dart';
|
||||||
import 'package:superport_v2/widgets/app_shell.dart';
|
import 'package:superport_v2/widgets/app_shell.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
@@ -27,6 +30,7 @@ void main() {
|
|||||||
final session = _buildSession();
|
final session = _buildSession();
|
||||||
final authService = _createAuthService(session);
|
final authService = _createAuthService(session);
|
||||||
GetIt.I.registerSingleton<AuthService>(authService);
|
GetIt.I.registerSingleton<AuthService>(authService);
|
||||||
|
GetIt.I.registerSingleton<UserRepository>(_StubUserRepository());
|
||||||
addTearDown(authService.dispose);
|
addTearDown(authService.dispose);
|
||||||
await authService.login(
|
await authService.login(
|
||||||
const LoginRequest(identifier: 'user@example.com', password: 'secret'),
|
const LoginRequest(identifier: 'user@example.com', password: 'secret'),
|
||||||
@@ -37,10 +41,12 @@ void main() {
|
|||||||
await tester.tap(find.byIcon(lucide.LucideIcons.userRound));
|
await tester.tap(find.byIcon(lucide.LucideIcons.userRound));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('계정 정보'), findsOneWidget);
|
expect(find.text('내 정보'), findsOneWidget);
|
||||||
expect(find.text('김승인'), findsWidgets);
|
expect(find.text('김승인'), findsWidgets);
|
||||||
expect(find.text('E2025001'), findsOneWidget);
|
expect(find.text('E2025001'), findsOneWidget);
|
||||||
expect(find.text('물류팀'), findsOneWidget);
|
expect(find.text('물류팀'), findsOneWidget);
|
||||||
|
expect(find.text('연락처 / 이메일 수정'), findsOneWidget);
|
||||||
|
expect(find.text('비밀번호 변경'), findsOneWidget);
|
||||||
expect(find.textContaining('/approvals'), findsOneWidget);
|
expect(find.textContaining('/approvals'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -48,6 +54,7 @@ void main() {
|
|||||||
final session = _buildSession();
|
final session = _buildSession();
|
||||||
final authService = _createAuthService(session);
|
final authService = _createAuthService(session);
|
||||||
GetIt.I.registerSingleton<AuthService>(authService);
|
GetIt.I.registerSingleton<AuthService>(authService);
|
||||||
|
GetIt.I.registerSingleton<UserRepository>(_StubUserRepository());
|
||||||
addTearDown(authService.dispose);
|
addTearDown(authService.dispose);
|
||||||
await authService.login(
|
await authService.login(
|
||||||
const LoginRequest(identifier: 'user@example.com', password: 'secret'),
|
const LoginRequest(identifier: 'user@example.com', password: 'secret'),
|
||||||
@@ -66,6 +73,133 @@ void main() {
|
|||||||
expect(authService.session, isNull);
|
expect(authService.session, isNull);
|
||||||
expect(find.text('로그인 페이지'), findsOneWidget);
|
expect(find.text('로그인 페이지'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('내 정보 다이얼로그에서 연락처/이메일 저장 시 updateMe 호출', (tester) async {
|
||||||
|
final session = _buildSession();
|
||||||
|
final authService = _createAuthService(session);
|
||||||
|
final captured = <UserProfileUpdateInput>[];
|
||||||
|
final repository = _StubUserRepository(onUpdateMe: (input) async {
|
||||||
|
captured.add(input);
|
||||||
|
return UserAccount(
|
||||||
|
id: session.user.id,
|
||||||
|
employeeNo: session.user.employeeNo ?? '',
|
||||||
|
employeeName: session.user.name,
|
||||||
|
email: input.email,
|
||||||
|
mobileNo: input.phone,
|
||||||
|
group: UserGroup(id: 1, groupName: '물류팀'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
GetIt.I.registerSingleton<AuthService>(authService);
|
||||||
|
GetIt.I.registerSingleton<UserRepository>(repository);
|
||||||
|
addTearDown(authService.dispose);
|
||||||
|
await authService.login(
|
||||||
|
const LoginRequest(identifier: 'user@example.com', password: 'secret'),
|
||||||
|
);
|
||||||
|
|
||||||
|
final view = tester.view;
|
||||||
|
view.physicalSize = const Size(1600, 900);
|
||||||
|
view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(() {
|
||||||
|
view.resetPhysicalSize();
|
||||||
|
view.resetDevicePixelRatio();
|
||||||
|
});
|
||||||
|
|
||||||
|
await _pumpAppShell(tester);
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(lucide.LucideIcons.userRound));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.enterText(
|
||||||
|
find.byKey(const ValueKey('account_email_field')),
|
||||||
|
'new@superport.com',
|
||||||
|
);
|
||||||
|
await tester.enterText(
|
||||||
|
find.byKey(const ValueKey('account_phone_field')),
|
||||||
|
'+82-10-9999-8888',
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.text('저장'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(captured, hasLength(1));
|
||||||
|
final input = captured.single;
|
||||||
|
expect(input.email, 'new@superport.com');
|
||||||
|
expect(input.phone, '+82-10-9999-8888');
|
||||||
|
expect(authService.session?.user.email, 'new@superport.com');
|
||||||
|
expect(authService.session?.user.phone, '+82-10-9999-8888');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('비밀번호 변경 완료 시 강제 로그아웃 안내 후 세션 초기화', (tester) async {
|
||||||
|
final session = _buildSession();
|
||||||
|
final authService = _createAuthService(session);
|
||||||
|
UserProfileUpdateInput? passwordInput;
|
||||||
|
final repository = _StubUserRepository(onUpdateMe: (input) async {
|
||||||
|
passwordInput = input;
|
||||||
|
return UserAccount(
|
||||||
|
id: session.user.id,
|
||||||
|
employeeNo: session.user.employeeNo ?? '',
|
||||||
|
employeeName: session.user.name,
|
||||||
|
email: input.email ?? session.user.email,
|
||||||
|
mobileNo: input.phone ?? session.user.phone,
|
||||||
|
group: UserGroup(id: 1, groupName: '물류팀'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
GetIt.I.registerSingleton<AuthService>(authService);
|
||||||
|
GetIt.I.registerSingleton<UserRepository>(repository);
|
||||||
|
addTearDown(authService.dispose);
|
||||||
|
await authService.login(
|
||||||
|
const LoginRequest(identifier: 'user@example.com', password: 'secret'),
|
||||||
|
);
|
||||||
|
|
||||||
|
final view = tester.view;
|
||||||
|
view.physicalSize = const Size(1600, 900);
|
||||||
|
view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(() {
|
||||||
|
view.resetPhysicalSize();
|
||||||
|
view.resetDevicePixelRatio();
|
||||||
|
});
|
||||||
|
|
||||||
|
await _pumpAppShell(tester);
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(lucide.LucideIcons.userRound));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final changeButton = find.text('비밀번호 변경');
|
||||||
|
await tester.ensureVisible(changeButton);
|
||||||
|
await tester.tap(changeButton);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.enterText(
|
||||||
|
find.byKey(const ValueKey('account_current_password')),
|
||||||
|
'TempPass1!',
|
||||||
|
);
|
||||||
|
await tester.enterText(
|
||||||
|
find.byKey(const ValueKey('account_new_password')),
|
||||||
|
'Aa1!zzzz',
|
||||||
|
);
|
||||||
|
await tester.enterText(
|
||||||
|
find.byKey(const ValueKey('account_confirm_password')),
|
||||||
|
'Aa1!zzzz',
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.text('변경'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(passwordInput, isNotNull);
|
||||||
|
expect(passwordInput!.currentPassword, 'TempPass1!');
|
||||||
|
expect(passwordInput!.password, 'Aa1!zzzz');
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('비밀번호 변경 완료'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('확인'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(authService.session, isNull);
|
||||||
|
expect(find.text('로그인 페이지'), findsOneWidget);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pumpAppShell(WidgetTester tester) async {
|
Future<void> _pumpAppShell(WidgetTester tester) async {
|
||||||
@@ -122,6 +256,7 @@ AuthSession _buildSession() {
|
|||||||
name: '김승인',
|
name: '김승인',
|
||||||
employeeNo: 'E2025001',
|
employeeNo: 'E2025001',
|
||||||
email: 'approver@example.com',
|
email: 'approver@example.com',
|
||||||
|
phone: '+82-10-2222-1111',
|
||||||
primaryGroupId: 3,
|
primaryGroupId: 3,
|
||||||
primaryGroupName: '물류팀',
|
primaryGroupName: '물류팀',
|
||||||
),
|
),
|
||||||
@@ -194,3 +329,53 @@ class _LoginPlaceholder extends StatelessWidget {
|
|||||||
return const Scaffold(body: Center(child: Text('로그인 페이지')));
|
return const Scaffold(body: Center(child: Text('로그인 페이지')));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _StubUserRepository implements UserRepository {
|
||||||
|
_StubUserRepository({this.onUpdateMe});
|
||||||
|
|
||||||
|
final Future<UserAccount> Function(UserProfileUpdateInput input)? onUpdateMe;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<UserAccount> create(UserInput input) {
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> delete(int id) {
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<PaginatedResult<UserAccount>> list({
|
||||||
|
int page = 1,
|
||||||
|
int pageSize = 20,
|
||||||
|
String? query,
|
||||||
|
int? groupId,
|
||||||
|
bool? isActive,
|
||||||
|
}) {
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<UserAccount> resetPassword(int id) {
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<UserAccount> restore(int id) {
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<UserAccount> update(int id, UserInput input) {
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<UserAccount> updateMe(UserProfileUpdateInput input) {
|
||||||
|
if (onUpdateMe != null) {
|
||||||
|
return onUpdateMe!(input);
|
||||||
|
}
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user