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:
JiWoong Sul
2025-10-26 17:05:47 +09:00
parent 9beb161527
commit 14624c4165
23 changed files with 1958 additions and 194 deletions

View File

@@ -7,7 +7,7 @@
---
## 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/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`)과 단건 조회를 제공한다.
@@ -26,7 +26,7 @@
- **증분 조회:** `updated_since=ISO8601`.
- **정렬:** `sort`(기본 `updated_at`), `order=asc|desc`(기본 desc).
- **검색:** `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`를 명시).
- **에러 규격:**
@@ -130,7 +130,7 @@
---
## 3. 마스터 데이터 API
리소스: `/vendors`, `/warehouses`, `/customers`, `/employees`, `/products`, `/menus`, `/groups`, `/zipcodes`
리소스: `/vendors`, `/warehouses`, `/customers`, `/users`, `/products`, `/menus`, `/groups`, `/zipcodes`
> 기본 정렬: 별도 `sort` 파라미터가 없으면 항상 `id` 오름차순으로 응답을 정렬한다. (`order` 기본값도 `asc`)
@@ -245,20 +245,22 @@
> `contact_name`은 고객사 담당자 실명. 선택 입력이며 미입력 시 `null`.
`GET /employees?page=1`
`GET /users?page=1`
```json
{
"items": [
{
"id": 7,
"employee_no": "E2025001",
"employee_name": "김승인",
"employee_id": "E2025001",
"name": "김승인",
"email": "approver@example.com",
"mobile_no": "010-2222-1111",
"phone": "+82-10-2222-1111",
"group": {
"id": 2,
"group_name": "창고 관리자"
},
"force_password_change": false,
"password_updated_at": "2025-01-10T09:00:00Z",
"is_active": true,
"created_at": "2025-01-02T09:00:00Z",
"updated_at": "2025-01-10T11:00:00Z"
@@ -270,7 +272,7 @@
}
```
`GET /groups?include=permissions,employees`
`GET /groups?include=permissions,users`
```json
{
"items": [
@@ -295,11 +297,11 @@
"can_delete": false
}
],
"employees": [
"users": [
{
"id": 7,
"employee_no": "E2025001",
"employee_name": "김승인"
"employee_id": "E2025001",
"name": "김승인"
}
],
"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` 등 주소 구성 요소가 포함된다.
### 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
@@ -476,8 +579,8 @@
"transaction_date": "2025-09-18",
"created_by": {
"id": 7,
"employee_no": "E2025001",
"employee_name": "김승인"
"employee_id": "E2025001",
"name": "김승인"
},
"note": "창고 입고",
"is_active": true,
@@ -537,7 +640,7 @@
},
"approver": {
"id": 21,
"employee_no": "E2025002",
"employee_id": "E2025002",
"name": "박검토"
},
"assigned_at": "2025-09-18T06:05:00Z",
@@ -546,7 +649,7 @@
},
"requester": {
"id": 7,
"employee_no": "E2025001",
"employee_id": "E2025001",
"name": "김승인"
},
"requested_at": "2025-09-18T06:00:00Z",
@@ -593,8 +696,8 @@
"transaction_date": "2025-09-18",
"created_by": {
"id": 7,
"employee_no": "E2025001",
"employee_name": "김승인"
"employee_id": "E2025001",
"name": "김승인"
},
"note": "창고 입고",
"is_active": true,
@@ -649,7 +752,7 @@
"step_order": 1,
"approver": {
"id": 21,
"employee_no": "E2025002",
"employee_id": "E2025002",
"name": "박검토"
},
"status": {
@@ -664,7 +767,7 @@
},
"requester": {
"id": 7,
"employee_no": "E2025001",
"employee_id": "E2025001",
"name": "김승인"
},
"requested_at": "2025-09-18T06:00:00Z",
@@ -677,7 +780,7 @@
"step_order": 1,
"approver": {
"id": 21,
"employee_no": "E2025002",
"employee_id": "E2025002",
"name": "박검토"
},
"status": {
@@ -707,7 +810,7 @@
},
"approver": {
"id": 7,
"employee_no": "E2025001",
"employee_id": "E2025001",
"name": "김승인"
},
"action_at": "2025-09-18T06:00:00Z",
@@ -882,7 +985,7 @@
"current_step": null,
"requester": {
"id": 7,
"employee_no": "E2025001",
"employee_id": "E2025001",
"name": "김승인"
},
"requested_at": "2025-09-18T06:00:00Z",
@@ -929,7 +1032,7 @@
},
"approver": {
"id": 21,
"employee_no": "E2025002",
"employee_id": "E2025002",
"name": "박검토"
},
"assigned_at": "2025-09-18T06:05:00Z",
@@ -938,7 +1041,7 @@
},
"requester": {
"id": 7,
"employee_no": "E2025001",
"employee_id": "E2025001",
"name": "김승인"
},
"requested_at": "2025-09-18T06:00:00Z",
@@ -960,7 +1063,7 @@
},
"approver": {
"id": 21,
"employee_no": "E2025002",
"employee_id": "E2025002",
"name": "박검토"
},
"assigned_at": "2025-09-18T06:05:00Z",
@@ -984,7 +1087,7 @@
},
"approver": {
"id": 7,
"employee_no": "E2025001",
"employee_id": "E2025001",
"name": "김승인"
},
"action_at": "2025-09-18T06:00:00Z",
@@ -1028,7 +1131,7 @@
},
"approver": {
"id": 21,
"employee_no": "E2025002",
"employee_id": "E2025002",
"name": "박검토"
},
"assigned_at": "2025-09-18T06:05:00Z",
@@ -1037,7 +1140,7 @@
},
"requester": {
"id": 7,
"employee_no": "E2025001",
"employee_id": "E2025001",
"name": "김승인"
},
"requested_at": "2025-09-18T06:00:00Z",
@@ -1055,7 +1158,7 @@
},
"approver": {
"id": 21,
"employee_no": "E2025002",
"employee_id": "E2025002",
"name": "박검토"
},
"assigned_at": "2025-09-18T06:05:00Z",
@@ -1079,7 +1182,7 @@
},
"approver": {
"id": 7,
"employee_no": "E2025001",
"employee_id": "E2025001",
"name": "김승인"
},
"action_at": "2025-09-18T06:00:00Z",
@@ -1285,7 +1388,7 @@
"step_order": 2,
"approver": {
"id": 34,
"employee_no": "E2025003",
"employee_id": "E2025003",
"name": "최검토"
},
"status": {
@@ -1345,7 +1448,7 @@
"step_order": 2,
"approver": {
"id": 34,
"employee_no": "E2025003",
"employee_id": "E2025003",
"name": "최검토"
},
"status": {
@@ -1413,7 +1516,7 @@
},
"requester": {
"id": 7,
"employee_no": "E2025001",
"employee_id": "E2025001",
"name": "김승인"
},
"requested_at": "2025-09-18T06:00:00Z",
@@ -1445,8 +1548,8 @@
"note": "보류 코멘트",
"approver": {
"id": 21,
"employee_no": "E2025002",
"employee_name": "박검토"
"employee_id": "E2025002",
"name": "박검토"
},
"from_status": {
"id": 1,
@@ -1476,7 +1579,7 @@
"step_order": 1,
"approver": {
"id": 21,
"employee_no": "E2025002",
"employee_id": "E2025002",
"name": "박검토"
},
"status": {
@@ -1524,8 +1627,8 @@
"note": "보류 코멘트",
"approver": {
"id": 21,
"employee_no": "E2025002",
"employee_name": "박검토"
"employee_id": "E2025002",
"name": "박검토"
},
"from_status": null,
"to_status": {
@@ -1550,8 +1653,8 @@
"step_order": 1,
"approver": {
"id": 21,
"employee_no": "E2025002",
"employee_name": "박검토"
"employee_id": "E2025002",
"name": "박검토"
},
"status": {
"id": 2,
@@ -1581,8 +1684,8 @@
"description": "입고 결재 2단계",
"created_by": {
"id": 7,
"employee_no": "E2025001",
"employee_name": "김승인"
"employee_id": "E2025001",
"name": "김승인"
},
"is_active": true,
"created_at": "2025-01-20T00:00:00Z",
@@ -1594,6 +1697,7 @@
"total": 1
}
```
- `created_by`는 작성자의 `id`, `employee_id`, `name`을 포함하며 `include=` 파라미터 없이도 기본 반환된다.
### 6.2 단건 조회
`GET /approval-templates/3001?include=steps`
@@ -1606,8 +1710,8 @@
"description": "입고 결재 2단계",
"created_by": {
"id": 7,
"employee_no": "E2025001",
"employee_name": "김승인"
"employee_id": "E2025001",
"name": "김승인"
},
"steps": [
{
@@ -1615,8 +1719,8 @@
"step_order": 1,
"approver": {
"id": 21,
"employee_no": "E2025002",
"employee_name": "박검토"
"employee_id": "E2025002",
"name": "박검토"
},
"note": null
}
@@ -1733,7 +1837,7 @@
"user": {
"id": 7,
"name": "김승인",
"employee_no": "E2025001",
"employee_id": "E2025001",
"email": "approver@example.com",
"primary_group": {
"id": 3,
@@ -1765,6 +1869,7 @@
- 잘못된 자격 증명: `invalid credentials`
- 비활성 계정 접근: `account is inactive`
- 만료된 토큰: `token expired`
- 비밀번호 변경 등으로 무효화된 토큰: `token revoked`
- 재사용·서명 오류: `invalid token`
### 8.1 대시보드 요약
@@ -1798,7 +1903,7 @@
],
"pending_approvals": [
{
"approval_id": 5005,
"approval_id": 5001,
"approval_no": "APP-202511100005",
"title": "출고 결재",
"step_summary": "2단계/3단계 진행중",
@@ -1809,8 +1914,6 @@
}
```
- `pending_approvals[].approval_id`는 결재 상세 조회(`GET /approvals/{id}`)에 사용되는 `approvals.id` 값을 그대로 노출한다.
---
## 9. 구현 참고