@vtex/faststore-plugin-buyer-portal 2.0.5 → 2.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/CHANGELOG.md +8 -1
  2. package/package.json +1 -1
  3. package/specs/refactor-add-user-form.md +371 -0
  4. package/src/features/org-units/components/AuthSetupDrawer/AuthSetupDrawer.tsx +3 -103
  5. package/src/features/org-units/types/OrgUnitSettings.ts +0 -12
  6. package/src/features/org-units/types/index.ts +1 -1
  7. package/src/features/shared/layouts/LoadingTabsLayout/LoadingTabsLayout.tsx +2 -2
  8. package/src/features/shared/utils/constants.ts +2 -2
  9. package/src/features/users/clients/UsersClient.ts +10 -6
  10. package/src/features/users/components/CreateUserDrawer/CreateUserDrawer.tsx +2 -2
  11. package/src/features/users/components/CreateUserDrawerWithUsername/CreateUserDrawerWithUsername.tsx +112 -380
  12. package/src/features/users/components/CreateUserDrawerWithUsername/create-user-drawer-with-username.scss +1 -0
  13. package/src/features/users/components/EmailTransactionalSection/EmailTransactionalSection.tsx +69 -0
  14. package/src/features/users/components/LoginField/LoginField.tsx +54 -0
  15. package/src/features/users/components/LoginField/__tests__/LoginField.test.ts +89 -0
  16. package/src/features/users/components/UpdateUserDrawer/UpdateUserDrawer.tsx +2 -2
  17. package/src/features/users/components/UpdateUserDrawerWithUsername/UpdateUserDrawerWithUsername.tsx +132 -211
  18. package/src/features/users/components/UpdateUserDrawerWithUsername/update-user-drawer-with-username.scss +1 -0
  19. package/src/features/users/components/UserFormFields/UserFormFields.tsx +253 -0
  20. package/src/features/users/components/UserFormFields/user-form-fields.scss +13 -0
  21. package/src/features/users/hooks/useAddUserToOrgUnit.ts +33 -10
  22. package/src/features/users/layouts/UsersLayout/UsersLayout.tsx +4 -4
  23. package/src/features/users/mocks/users-data.ts +4 -2
  24. package/src/features/users/services/add-user-to-org-unit.service.ts +51 -12
  25. package/src/features/users/services/get-user-by-id.service.ts +13 -3
  26. package/src/features/users/services/get-users-by-org-unit-id.service.ts +3 -2
  27. package/src/features/users/services/index.ts +1 -0
  28. package/src/features/users/services/update-user.service.ts +6 -3
  29. package/src/features/users/types/UserData.ts +4 -2
  30. package/src/features/users/types/UserDataService.ts +2 -0
  31. package/src/features/users/utils/__tests__/detectLoginType.test.ts +79 -0
  32. package/src/features/users/utils/detectLoginType.ts +36 -0
package/CHANGELOG.md CHANGED
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.0.6] - 2026-06-26
11
+
12
+ ### Changed
13
+
14
+ - Refactor Add/Edit User form (unified login): replace username-centric fields with a single `login` field that dynamically adapts UI per type (email/phone/username), remove `generateUsernameSuggestion` email→username transform, update User List to show `login` as primary column, and remove the "User identification" section from `AuthSetupDrawer`
15
+
10
16
  ## [2.0.5] - 2026-06-17
11
17
 
12
18
  ### Fixed
@@ -702,7 +708,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
702
708
  - Add CHANGELOG file
703
709
  - Add README file
704
710
 
705
- [unreleased]: https://github.com/vtex/faststore-plugin-buyer-portal/compare/v2.0.5...HEAD
711
+ [unreleased]: https://github.com/vtex/faststore-plugin-buyer-portal/compare/v2.0.6...HEAD
706
712
  [1.3.55]: https://github.com/vtex/faststore-plugin-buyer-portal/compare/v1.3.54...v1.3.55
707
713
  [1.3.54]: https://github.com/vtex/faststore-plugin-buyer-portal/compare/v1.3.53...v1.3.54
708
714
  [1.3.53]: https://github.com/vtex/faststore-plugin-buyer-portal/compare/v1.3.52...v1.3.53
@@ -788,6 +794,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
788
794
  [1.3.85]: https://github.com/vtex/faststore-plugin-buyer-portal/releases/tag/1.3.85
789
795
  [1.3.87]: https://github.com/vtex/faststore-plugin-buyer-portal/compare/v1.3.86...v1.3.87
790
796
  [1.3.86]: https://github.com/vtex/faststore-plugin-buyer-portal/releases/tag/1.3.86
797
+ [2.0.6]: https://github.com/vtex/faststore-plugin-buyer-portal/compare/v2.0.5...v2.0.6
791
798
  [2.0.5]: https://github.com/vtex/faststore-plugin-buyer-portal/compare/v2.0.4...v2.0.5
792
799
  [2.0.4]: https://github.com/vtex/faststore-plugin-buyer-portal/compare/v2.0.3...v2.0.4
793
800
  [2.0.3]: https://github.com/vtex/faststore-plugin-buyer-portal/compare/v2.0.2...v2.0.3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vtex/faststore-plugin-buyer-portal",
3
- "version": "2.0.5",
3
+ "version": "2.0.6",
4
4
  "description": "A plugin for faststore with buyer portal",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -0,0 +1,371 @@
1
+ # Refactor Add/Edit User Form — Unified Login
2
+
3
+ > **Status**: Approved
4
+ > **Created**: 2026-05-22
5
+ > **Implementation PR base branch**: `refactor/add-user-form`
6
+ > **Implementation PR labels**: `skip-changelog`
7
+
8
+ ---
9
+
10
+ ## 1. Business Context
11
+
12
+ ### Problem Statement
13
+
14
+ The add/edit user form in Org Account (flow `enableAlternativeLogin = true`, components `CreateUserDrawerWithUsername` and `UpdateUserDrawerWithUsername`) treats **Username as the mandatory primary identifier**, which contradicts the current model where any credential type (email, phone, username) can be the user's login.
15
+
16
+ There is also an active bug: when an email address is typed in the "Email" field, a `useEffect` extracts the local-part (everything before `@`) and automatically fills the "Username" field with that value — with no visual indication to the admin. The persisted value is therefore different from what the administrator typed or intended to use as the identifier, causing confusion and potentially breaking access for the registered user.
17
+
18
+ Other problems stemming from the current model:
19
+
20
+ - The "User identification" section in `AuthSetupDrawer` shows toggles for Username/Email/Phone, but "Username" is always locked as required — semantically inconsistent with the new unified Login reality.
21
+ - The User List displays "Username" as the primary column and search operates on the `name` field in the backend, not on the user's login identifier.
22
+ - UI fields and behaviours (e.g. "username suggestions", async username uniqueness validation) no longer make sense with the unified Login model.
23
+
24
+ ### Goals
25
+
26
+ 1. Make **Login** the only required field in the add/edit user form; Full Name becomes optional.
27
+ 2. Implement **dynamic UI behaviour** based on the detected login type (email / phone / username) without persisting that inference.
28
+ 3. Eliminate the **automatic email → username transformation** and any other implicit transformation of the typed value.
29
+ 4. Preserve the **exact value typed** in the Login field (no forced lowercase, aggressive trimming, or domain removal).
30
+ 5. Update the **User List** to display Login as the primary identifier, with search and sorting operating on that field.
31
+ 6. Clean up the **AuthSetupDrawer** by removing the "User identification" section entirely — visual, state, payload field, and type definition.
32
+
33
+ ### User Stories
34
+
35
+ #### US-1: Create user with email login
36
+
37
+ - **Story**: As an organisation administrator, I want to register a new user using an email address as login, so that the user can access the portal with their email credentials.
38
+ - **Acceptance Criteria**:
39
+ - **Given** the "Add User" drawer is open, **when** the admin fills the Login field with a value containing `@` (valid email), **then** the UI displays a "Use this email for transactional notifications" checkbox checked by default.
40
+ - **Given** the "Use this email for transactional notifications" checkbox is checked, **when** the admin submits the form, **then** the payload sent to the API contains `login = <typed value>` and `transactionalEmail = <same value>`.
41
+ - **Given** the checkbox is unchecked, **when** the admin does not fill the additional "Transactional Email" field, **then** the submit button remains disabled and an error message is shown in the field.
42
+ - **Given** the checkbox is unchecked and the "Transactional Email" field was filled with a valid email, **when** the admin submits, **then** the payload contains `login = <typed value>`, `transactionalEmail = <transactional email>`.
43
+ - **Given** login is email, **when** the UI renders, **then** the "Username" field is **not** displayed.
44
+ - **Given** login is email, **when** the UI renders, **then** an optional phone field is displayed.
45
+
46
+ #### US-2: Create user with phone login
47
+
48
+ - **Story**: As an organisation administrator, I want to register a user using a phone number as login, so that the user can authenticate via SMS/code.
49
+ - **Acceptance Criteria**:
50
+ - **Given** the Login field contains only digits, `+`, spaces, `(`, `)` or `-` in an E.164-compatible format, **when** the UI detects the type, **then** it classifies as `phone` and displays an optional email field.
51
+ - **Given** login is phone and no additional email was provided, **when** the UI renders, **then** it displays the warning: _"If no email is provided, only an administrator can recover access (no SMS recovery available)"_.
52
+ - **Given** login is phone, **when** the UI renders, **then** the optional email field is displayed (not required by default).
53
+ - **Given** login is phone, **when** the UI renders, **then** the "Additional Phone" field is **not** displayed (login is already the phone).
54
+
55
+ #### US-3: Create user with username login
56
+
57
+ - **Story**: As an organisation administrator, I want to register a user with an arbitrary username as login, so that the portal supports identifiers not tied to email or phone.
58
+ - **Acceptance Criteria**:
59
+ - **Given** the Login field does not contain `@` and does not match the phone pattern, **when** the UI detects the type, **then** it classifies as `username`.
60
+ - **Given** login is username, **when** the UI renders, **then** it displays an optional email field, an optional phone field, and the recovery warning: _"If no email is provided, only an administrator can recover access (no SMS recovery available)"_.
61
+ - **Given** login is username, **when** the admin types the value, **then** the value is preserved exactly (no lowercase, no domain removal).
62
+
63
+ #### US-4: Edit existing user
64
+
65
+ - **Story**: As an administrator, I want to edit an existing user's data with the same rules as registration, so that the unified Login model is consistent between creation and editing.
66
+ - **Acceptance Criteria**:
67
+ - **Given** the "Edit User" drawer is open with an existing user's data, **when** Login is displayed, **then** the field is editable and pre-filled with the current value (`login` or legacy `userName` via defensive mapping).
68
+ - **Given** the admin changes the Login field to a value of a different type (e.g. from email to username), **when** the type changes, **then** the dynamic UI immediately adapts the additional fields displayed.
69
+ - **Given** the edit form is opened, **when** the current Login is an email, **then** the "Use this email for transactional notifications" checkbox is pre-checked if `transactionalEmail == login`, unchecked otherwise.
70
+
71
+ #### US-5: List users with Login as primary identifier
72
+
73
+ - **Story**: As an administrator, I want to see Login as the main column in the user list, so that the identifier with which the user accesses the system is immediately visible.
74
+ - **Acceptance Criteria**:
75
+ - **Given** the User List is loaded, **when** the table renders, **then** the first column displays the user's `login` field (or `userName` as fallback for legacy records).
76
+ - **Given** the admin types in the search field, **when** the request is sent to the backend, **then** the search parameter operates on the Login field (`login` parameter confirmed).
77
+ - **Given** a legacy record without a `login` field in the API response, **when** the User List renders, **then** it displays the `userName` value as fallback without error.
78
+
79
+ ### Key Scenarios
80
+
81
+ | Scenario | Pre-conditions | Steps | Expected result |
82
+ |---|---|---|---|
83
+ | Happy path — email login with default transactional | "Add User" drawer open, flag `enableAlternativeLogin = true` | Fill Login with `joao@empresa.com`, leave checkbox checked, select role, click Add | User created with `login = joao@empresa.com`, `transactionalEmail = joao@empresa.com` |
84
+ | Happy path — email login with different transactional | Drawer open | Fill Login with `joao@empresa.com`, uncheck checkbox, fill Transactional Email with `notif@empresa.com`, select role, click Add | User created with `login = joao@empresa.com`, `transactionalEmail = notif@empresa.com` |
85
+ | Happy path — phone login without email | Drawer open | Fill Login with `+5511999990000`, select role, click Add | User created with `login = +5511999990000`; recovery warning was shown during filling |
86
+ | Happy path — username login | Drawer open | Fill Login with `joao.silva`, select role, click Add | User created with `login = joao.silva`; recovery warning displayed |
87
+ | Error — empty required Login | Drawer open | Click Add without filling Login | Button disabled while Login is empty; error message shown on field touch |
88
+ | Error — invalid Transactional Email | Login is email, checkbox unchecked | Fill Transactional Email with `invalido`, click Add | Invalid format error message; submit blocked |
89
+ | Edge case — type change while typing | Login = `joao@` (incomplete email) | Delete `@`, turning it into `joao` | Type changes to `username`; transactional checkbox disappears; recovery warning appears |
90
+ | Edge case — uppercase value preserved | Drawer open | Fill Login with `JOAO.SILVA` | Value sent to backend is `JOAO.SILVA` (no lowercase) |
91
+ | Edge case — international phone | Drawer open | Fill Login with `+44 20 7946 0958` | Type detected as `phone`; dynamic phone fields displayed |
92
+ | Edge case — legacy user in Edit | Existing user has only `userName` (no `login`) | Open edit drawer | Login field pre-filled with `userName` value; UI functional without error |
93
+ | Edge case — email login, additional Phone field | Login = detected email | UI renders | Optional Phone field visible (email login allows additional phone) |
94
+ | Edge case — phone login, additional Phone field | Login = detected phone | UI renders | Additional Phone field **not** visible (login is already the phone) |
95
+
96
+ ### Functional Requirements
97
+
98
+ **Fixed fields (always present, in this order):**
99
+ - `Full Name` — free text, optional. Displayed before the Login field.
100
+ - `Login` — required, accepts any non-empty string; dynamic behaviour based on the inferred type.
101
+
102
+ **Dynamic behaviour — Case A (Login is email):**
103
+ - Display "Use this email for transactional notifications" checkbox (checked by default).
104
+ - If unchecked: display required "Transactional Email" field with format validation.
105
+ - Display optional Phone field.
106
+ - Do not display Username field.
107
+
108
+ **Dynamic behaviour — Case B (Login is phone):**
109
+ - Display optional Email field.
110
+ - Display warning: _"If no email is provided, only an administrator can recover access (no SMS recovery available)"_.
111
+ - Do not display additional Phone field.
112
+ - The Login field applies a phone mask during typing (e.g. `+1 (555) 123-4567`). The payload sent to the API contains the normalised number: `+` followed by digits only (e.g. `+15551234567`), with no spaces or separators.
113
+ - **Known limitation**: the display mask currently supports the North American format only (`+1 (NXX) NXX-XXXX`). Numbers from other countries are detected and normalised correctly for the payload, but the visual mask may format the country code incorrectly. Multi-country support (i18n) will be implemented in a future task.
114
+
115
+ **Dynamic behaviour — Case C (Login is username):**
116
+ - Display optional Email field.
117
+ - Display optional Phone field.
118
+ - Display warning: _"If no email is provided, only an administrator can recover access (no SMS recovery available)"_.
119
+
120
+ **Submission rules:**
121
+ - Submit enabled only when: Login filled + (if unchecked: Transactional Email filled and valid) + at least 1 role selected.
122
+ - Login value sent byte-exact (no UI-layer normalisation), **except when login is of type `phone`**: in that case the payload contains `+` followed by digits only (E.164-like format), removing spaces and separators from the display mask.
123
+
124
+ **Email → username transformation:**
125
+ - Remove `generateUsernameSuggestion` and the `useEffect` that calls it in `CreateUserDrawerWithUsername`.
126
+
127
+ **User List:**
128
+ - Primary column changes from `userName` to `login` (with fallback to `userName` for legacy records).
129
+ - Search operates on `login` (parameter confirmed by the backend).
130
+
131
+ **AuthSetupDrawer:**
132
+ - Remove the "User identification" visual section (Username/Email/Phone checkboxes) and associated state/handlers (`selectedIdentifiers`, `handleIdentifierChange`, username required validation).
133
+ - Remove `userIdentification` from the `OrgUnitSettings` type, from the `PATCH /units/{id}/settings` payload, and from any mock/loading skeleton that previously required the field. The backend no longer requires this field.
134
+
135
+ ### Non-Functional Requirements
136
+
137
+ - **Reactivity**: the dynamic UI (conditional fields, warnings, checkbox) must re-render synchronously on every keystroke in the Login field, with no debounce.
138
+ - **Accessibility**: warnings and error messages must use `aria-describedby` or `role="alert"` for screen readers. Conditional fields that appear/disappear must keep focus manageable.
139
+ - **i18n readiness**: form strings must be declared as literal constants (no dynamic string concatenation), to ease future extraction to locale files.
140
+ - **Test coverage**: component tests covering the 3 dynamic flows (email / phone / username), the transactional checkbox behaviour, the recovery warnings, and the absence of the email → username transformation. Minimum coverage: Definition of Done scenarios.
141
+ - **No new form library dependency**: keep local `useState` consistent with the current project convention.
142
+
143
+ ### Out of Scope
144
+
145
+ - Refactoring the legacy `CreateUserDrawer` / `UpdateUserDrawer` flow (flag `enableAlternativeLogin = false`).
146
+ - Real internationalisation of strings (only prepare the structure to ease future translation).
147
+ - Changes to `AuthSetupDrawer` beyond the removal of the "User identification" section.
148
+ - Backend contract changes — this spec documents the expected new payload; backend contract implementation is the responsibility of another team.
149
+ - Removal of the `enableAlternativeLogin` feature flag or the `Selector`.
150
+ - Implementation of async username uniqueness validation (removed along with the field).
151
+
152
+ ---
153
+
154
+ ## 2. Arch Decisions
155
+
156
+ ### Proposed Solution
157
+
158
+ Rebuild both `CreateUserDrawerWithUsername` and `UpdateUserDrawerWithUsername` drawers keeping local `useState` as the form state mechanism (consistent with the existing project pattern). Login type inference is extracted into a pure utility function `detectLoginType`. The dynamic UI is isolated in small sub-components reusable between create and update.
159
+
160
+ `CreateUserDrawerSelector` and `UpdateUserDrawerSelector` are not changed — they continue selecting the correct drawer based on the `enableAlternativeLogin` flag.
161
+
162
+ ### Architecture Overview
163
+
164
+ ```mermaid
165
+ flowchart TD
166
+ Selector[CreateUserDrawerSelector] -->|enableAlternativeLogin=true| Drawer[CreateUserDrawerWithUsername]
167
+ Drawer --> LoginField[LoginField component]
168
+ LoginField --> detectLoginType[detectLoginType pure]
169
+ detectLoginType -->|email| EmailUI[EmailTransactionalSection]
170
+ detectLoginType -->|phone| RecoveryAlert[Alert WarningCircle warning]
171
+ detectLoginType -->|username| RecoveryAlert
172
+ detectLoginType -->|phone| PhoneHidden[no additional Phone]
173
+ EmailUI --> PhoneOptional[optional Phone]
174
+ RecoveryAlert --> EmailOptional[optional Email]
175
+ Drawer --> Submit[Submit handler]
176
+ Submit --> Payload[payload: login + extras]
177
+ Payload --> ServiceV2[addUserToOrgUnitV2]
178
+ ServiceV2 --> APIV2[POST v3/units/orgUnitId/users]
179
+ ```
180
+
181
+ The `UpdateUserDrawerWithUsername` flow is identical, with the difference that fields are pre-filled from the existing user's data (with defensive mapping `login ?? userName`).
182
+
183
+ ### Alternatives Considered
184
+
185
+ | Alternative | Pros | Cons | Verdict |
186
+ |---|---|---|---|
187
+ | Three separate forms by login type | Internal simplicity of each form | Poor UX: admin discovers the type only after typing; type change requires reopening the drawer | Rejected |
188
+ | Adopt `react-hook-form` for state and validation management | Less boilerplate, declarative validation | Paradigm shift for the entire project; inflated scope | Rejected — out of scope for this refactoring |
189
+ | Detect login type only at submit time | Simpler implementation | Does not meet the reactive dynamic UI requirement during typing | Rejected |
190
+ | New separate feature flag (e.g. `unifiedLoginForm`) | Gradual rollout without touching the legacy code | Third flag for the same flow; growing complexity | Rejected — replacing `*WithUsername` is sufficient and cleaner |
191
+
192
+ ### Risks & Mitigations
193
+
194
+ | Risk | Impact | Probability | Mitigation |
195
+ |---|---|---|---|
196
+ | ~~Backend does not yet support `login` field in the payload~~ | ~~High~~ | ~~Medium~~ | `login` field confirmed by the backend for v2 endpoints (create and update). Risk resolved. |
197
+ | Legacy users have no `login` field in the API response | Medium — Login column shows empty in the list | High — all users registered today are legacy | Defensive mapping: `user.login ?? user.userName ?? "-"` in the loader and User List |
198
+ | Removing the "User identification" section in AuthSetupDrawer confuses admins | Low — field was functional but semantically inconsistent | Low | Analytics event recorded; no additional message to the user (silent behaviour, section simply disappears). Backend confirmed to no longer require `userIdentification` in the payload — field removed from type, service and mock. |
199
+ | `detectLoginType` produces false positives on ambiguous strings | Low — only affects dynamic UI, not payload | Low | Conservative threshold: phone regex requires a minimum of 7 digits; `@` always indicates email. Ambiguous cases fall through to `username`. |
200
+ | E2E tests (Cypress) cover only the legacy flow | Medium — no automated coverage for the new flow | High — confirmed by current state | Add component tests (Vitest) for the 3 scenarios as part of this spec's DoD |
201
+
202
+ ### Key Decisions
203
+
204
+ #### Decision 1: Scope — replace only the `*WithUsername` flow
205
+
206
+ - **Status**: Accepted
207
+ - **Context**: The repository has two parallel add/edit user flows, controlled by `enableAlternativeLogin`. The legacy flow (`CreateUserDrawer`) treats email as required and will not be changed.
208
+ - **Decision**: Replace the contents of `CreateUserDrawerWithUsername` and `UpdateUserDrawerWithUsername`. The `Selector` and the flag remain intact. The legacy flow is preserved as-is.
209
+ - **Consequences**: Lower risk of regression in the legacy flow. When the flag is eventually disabled, the legacy code can be removed in a separate task.
210
+
211
+ #### Decision 2: `detectLoginType` as an isolated pure function
212
+
213
+ - **Status**: Accepted
214
+ - **Context**: Login type inference is used exclusively to control the UI. Mixing it into components makes unit testing and reuse harder.
215
+ - **Decision**: Create `src/features/users/utils/detectLoginType.ts` exporting `detectLoginType(value: string): LoginType`. Logic: presence of `@` → `"email"`; match with E.164-like phone regex (`/^\+?[0-9][0-9\s\-()\u202f.]{5,}$/`) → `"phone"`; fallback → `"username"`. The function is **never** called outside the UI layer.
216
+ - **Consequences**: Testable in isolation (pure function). Does not leak into services, mutation hooks, or the payload.
217
+
218
+ #### Decision 3: Single `login` field in the API payload
219
+
220
+ - **Status**: Accepted
221
+ - **Context**: The current v2 backend expects separate `userName`, `email`, `phone` fields. The new model needs a `login` field representing the primary identifier regardless of type.
222
+ - **Decision**: Send `login` as a required field in the payload. The `email` and `phone` fields continue to be sent when filled by the user as additional contact (not derived from login). Include `transactionalEmail` as a separate field when applicable.
223
+ - **Consequences**: Confirmed by the backend. The `addUserToOrgUnitV2` service receives required `login` and optional `transactionalEmail`; the update endpoint accepts `login` as an optional field.
224
+
225
+ #### Decision 4: "Use this email for transactional notifications" checkbox
226
+
227
+ - **Status**: Accepted
228
+ - **Context**: When login is an email, it is expected that this email will be used for transactional notifications. But the admin may want to use a different email for notifications.
229
+ - **Decision**: Checkbox checked by default (implies `transactionalEmail = login`). When unchecked, the "Transactional Email" field appears as required. If unchecked and field is empty, submit is blocked.
230
+ - **Consequences**: More explicit UX for a case that was previously opaque. Payload will always include `transactionalEmail` when login is an email.
231
+
232
+ #### Decision 5: User List — column and search
233
+
234
+ - **Status**: Accepted
235
+ - **Context**: The User List displays "Username" as the main column. With unified Login, "Login" should be the primary identifier displayed.
236
+ - **Decision**: Change `getTableColumns` in `UsersLayout` to use `nameColumnLabel: "Login"` and `nameColumnKey: "login"`. Render `user.login ?? user.userName ?? "-"` for compatibility with legacy records. API search parameter uses `login` (confirmed by the backend).
237
+ - **Consequences**: Table displays the correct identifier. Legacy records degrade gracefully via fallback.
238
+
239
+ #### Decision 6: AuthSetupDrawer — complete removal of the "User identification" section
240
+
241
+ - **Status**: Accepted (revised)
242
+ - **Context**: The "User identification" section has toggles for Username (always required/disabled), Email, and Phone. With unified Login, this section has lost its semantics — any identifier type is accepted. The backend has confirmed it no longer requires the `userIdentification` field in the payload.
243
+ - **Decision**: Remove the section entirely: JSX, `selectedIdentifiers` and `handleIdentifierChange` state/handlers (already done in the visual pass), the `userIdentification` field from `OrgUnitSettings` type, the fixed hardcoded block from `handleConfirmClick`, and any mock/skeleton that referenced the field. The `Identifier` type (dead code left from the original section) is also removed.
244
+ - **Consequences**: Cleaner UI and leaner type surface. No legacy dead fields in the settings payload. Backend contract is maintained because the field is no longer expected by the backend.
245
+
246
+ #### Decision 7: Edit — Login editable with full dynamic behaviour
247
+
248
+ - **Status**: Accepted
249
+ - **Context**: In the current flow, Username is readonly in edit if the user already had a username. The new specification requires Login to be editable.
250
+ - **Decision**: Login is always editable in the edit drawer. The dynamic behaviour (conditional fields, checkbox, warnings) is the same as in the create drawer. The initial value is `user.login ?? user.userName`.
251
+ - **Consequences**: Admin can change an existing user's login identifier. This may have authentication implications — the decision to allow or disallow this must be validated with the backend.
252
+
253
+ ### Implementation Plan
254
+
255
+ The following phases must be implemented in sequence within PRs to the `refactor/add-user-form` branch:
256
+
257
+ 1. **`detectLoginType` utility** — create `src/features/users/utils/detectLoginType.ts` + unit tests covering all types and edge cases.
258
+ 2. **Refactor `CreateUserDrawerWithUsername`** — rebuild the create drawer with the new field logic, extracting `LoginField` and `EmailTransactionalSection` as reusable sub-components. The recovery warning is rendered inline via `Alert` with `WarningCircle` icon. Remove `generateUsernameSuggestion` and the associated `useEffect`. Add component tests for the 3 flows.
259
+ 3. **Refactor `UpdateUserDrawerWithUsername`** — reuse create sub-components. Implement defensive mapping `login ?? userName` on received data. Add component tests.
260
+ 4. **Update User List** — change `UsersLayout` (primary column `login`) and `UsersClient` (search parameter `login` confirmed by the backend).
261
+ 5. **Clean up `AuthSetupDrawer`** — remove "User identification" visual section and associated state; ensure fixed payload.
262
+ 6. **Regression test** — confirm that the email → username transformation no longer exists anywhere in the code.
263
+
264
+ ---
265
+
266
+ ## 3. Technical Contract
267
+
268
+ ### Data Models
269
+
270
+ ```typescript
271
+ // New type to represent the login type inference result (UI only)
272
+ export type LoginType = "email" | "phone" | "username";
273
+
274
+ // Updated UserData (src/features/users/types/UserData.ts)
275
+ export type UserData = {
276
+ id: string;
277
+ login: string; // primary identifier — new field
278
+ name: string;
279
+ userName?: string; // kept for compatibility with legacy records
280
+ email?: string;
281
+ phone?: string;
282
+ transactionalEmail?: string;
283
+ isActive?: boolean;
284
+ roles?: string[];
285
+ orgUnit: {
286
+ id?: string;
287
+ name: string;
288
+ };
289
+ };
290
+
291
+ // User creation payload (src/features/users/services/add-user-to-org-unit.service.ts)
292
+ export type CreateUserPayload = {
293
+ orgUnitId: string;
294
+ login: string; // required — new field
295
+ role: number[]; // at least 1
296
+ name?: string;
297
+ email?: string; // only if filled by the admin (never derived from login)
298
+ phone?: string; // only if filled by the admin
299
+ transactionalEmail?: string; // required when login is email
300
+ };
301
+
302
+ // Update payload (src/features/users/services/update-user.service.ts)
303
+ export type UpdateUserPayload = {
304
+ orgUnitId: string;
305
+ userId: string;
306
+ login?: string;
307
+ name?: string;
308
+ email?: string;
309
+ phone?: string;
310
+ transactionalEmail?: string;
311
+ role?: string;
312
+ };
313
+ ```
314
+
315
+ ### Interfaces
316
+
317
+ ```typescript
318
+ // src/features/users/utils/detectLoginType.ts
319
+ /**
320
+ * Infers the login type from the typed value.
321
+ * Result is used ONLY for UI behaviour — never persisted in the payload.
322
+ *
323
+ * Rules:
324
+ * - Contains "@" → "email"
325
+ * - Matches E.164-like phone pattern (minimum 7 chars, may have +, spaces, parentheses, hyphens) → "phone"
326
+ * - Any other value → "username"
327
+ */
328
+ export function detectLoginType(value: string): LoginType;
329
+
330
+ // New components (src/features/users/components/)
331
+ // LoginField — login field with label, inline validation and no transformation
332
+ interface LoginFieldProps {
333
+ value: string;
334
+ onChange: (value: string) => void;
335
+ isTouched: boolean;
336
+ "data-testid"?: string;
337
+ }
338
+
339
+ // EmailTransactionalSection — displayed when loginType === "email"
340
+ interface EmailTransactionalSectionProps {
341
+ useLoginAsTransactional: boolean;
342
+ onCheckboxChange: (checked: boolean) => void;
343
+ transactionalEmail: string;
344
+ onTransactionalEmailChange: (value: string) => void;
345
+ isTouched: boolean;
346
+ }
347
+
348
+ // Recovery warning — rendered inline via <Alert> with WarningCircle icon
349
+ // (no dedicated component; displayed in UserFormFields when loginType is phone or username)
350
+ ```
351
+
352
+ ### Integration Points
353
+
354
+ | Integration point | Current state | Expected change |
355
+ |---|---|---|
356
+ | `usersClient.addUserToOrgUnitV2` | Receives `{ userName, email?, phone?, name?, role[] }` | Receives `CreateUserPayload` with required `login` (confirmed) |
357
+ | `usersClient.updateUser` | Receives `{ name?, email?, phone?, role?, userName? }` | Receives `UpdateUserPayload` with optional `login?` (confirmed) |
358
+ | `getUsersByOrgUnitIdService` | Returns `UserDataService[]` with `userName` field | Defensive mapping in loader: `login: user.login ?? user.userName`; `transactionalEmail` mapped directly |
359
+ | `UsersClient.getUsersByOrgUnitId` | Search param: `name: search` | Search param: `login: search` (confirmed) |
360
+ | `AuthSetupDrawer → useUpdateOrgUnitSettings` | Sends `userIdentification` based on selected checkboxes | `userIdentification` removed from payload entirely; backend no longer requires the field |
361
+
362
+ ### Invariants & Constraints
363
+
364
+ 1. **Login is never transformed by the UI** (except for phone): for `email` or `username` login types, the payload value is byte-exact with what the user typed — no UI layer should apply `.toLowerCase()`, aggressive `.trim()`, or part removal (e.g. email domain). For the `phone` type, `LoginField` applies a display mask during typing and the payload is normalised to `+` + digits (no spaces or separators), equivalent to E.164 format without length restriction.
365
+ 2. **Username is not required**: at no point in the `enableAlternativeLogin = true` flow may a `userName` field be marked as required or block submit when empty.
366
+ 3. **Email is never converted to username**: the `generateUsernameSuggestion` function and any `useEffect` that copies part of the email to `userName` must be removed and no equivalent logic may exist.
367
+ 4. **`detectLoginType` is UI-exclusive**: the function must not be imported in services, mutation hooks, or any layer that produces API payloads.
368
+ 5. **Additional Phone field is invalid when login is phone**: if `detectLoginType(login) === "phone"`, the "Additional Phone" field must not be rendered, and no additional phone value must be sent in the payload.
369
+ 6. **`transactionalEmail` required when login is email and checkbox is unchecked**: submit must be blocked if `detectLoginType(login) === "email"` and `useLoginAsTransactional === false` and `transactionalEmail.trim() === ""`.
370
+ 7. **Roles required**: at least 1 role must be selected to enable submit (invariant maintained from the current flow).
371
+ 8. **Defensive mapping in edit**: when loading a user's data into the edit drawer, the `login` value must be `user.login ?? user.userName ?? ""`. No field may silently be `undefined`.
@@ -8,17 +8,12 @@ import {
8
8
  BasicDrawer,
9
9
  type AuthSettingsFeatureFlags,
10
10
  type BasicDrawerProps,
11
- ErrorMessage,
12
11
  Icon,
13
12
  } from "../../../shared/components";
14
13
  import { useAnalytics, useBuyerPortal } from "../../../shared/hooks";
15
14
  import { CHANGES_TIMEOUT_MESSAGE } from "../../../shared/utils/constants";
16
15
  import { useUpdateOrgUnitSettings } from "../../hooks";
17
- import {
18
- AuthenticationMethod,
19
- type Identifier,
20
- type OrgUnitSettings,
21
- } from "../../types";
16
+ import { AuthenticationMethod, type OrgUnitSettings } from "../../types";
22
17
 
23
18
  export type AuthSetupDrawerProps = Omit<BasicDrawerProps, "children"> & {
24
19
  id: string;
@@ -27,27 +22,6 @@ export type AuthSetupDrawerProps = Omit<BasicDrawerProps, "children"> & {
27
22
  onSuccess?: () => void;
28
23
  };
29
24
 
30
- const IDENTIFIERS: Identifier[] = [
31
- {
32
- id: "userName",
33
- name: "Username",
34
- disabled: true,
35
- defaultChecked: true,
36
- },
37
- {
38
- id: "email",
39
- name: "Email",
40
- disabled: false,
41
- defaultChecked: false,
42
- },
43
- {
44
- id: "phone",
45
- name: "Phone",
46
- disabled: false,
47
- defaultChecked: false,
48
- },
49
- ];
50
-
51
25
  type ActiveAuthMethods = Record<AuthenticationMethod, boolean>;
52
26
 
53
27
  type AuthMethodOption = {
@@ -57,7 +31,7 @@ type AuthMethodOption = {
57
31
  };
58
32
 
59
33
  function getAuthMethodOptions(
60
- authSettings?: AuthSettingsFeatureFlags
34
+ authSettings?: AuthSettingsFeatureFlags | undefined
61
35
  ): AuthMethodOption[] {
62
36
  const password = authSettings?.password ?? true;
63
37
  const sso = authSettings?.sso ?? true;
@@ -89,14 +63,6 @@ function getAuthMethodOptions(
89
63
  return options;
90
64
  }
91
65
 
92
- const getInitialIdentifiers = (settings: OrgUnitSettings): string[] => {
93
- const identifiers: string[] = [];
94
- if (settings.userIdentification.userName) identifiers.push("userName");
95
- if (settings.userIdentification.email) identifiers.push("email");
96
- if (settings.userIdentification.phone) identifiers.push("phone");
97
- return identifiers;
98
- };
99
-
100
66
  const getInitialActiveAuthMethods = (
101
67
  settings: OrgUnitSettings
102
68
  ): ActiveAuthMethods => {
@@ -144,13 +110,9 @@ export const AuthSetupDrawer = ({
144
110
  shouldTrackDefaultTimer: true,
145
111
  });
146
112
 
147
- const [selectedIdentifiers, setSelectedIdentifiers] = useState<string[]>(() =>
148
- getInitialIdentifiers(settings)
149
- );
150
113
  const [activeAuthMethods, setActiveAuthMethods] = useState<ActiveAuthMethods>(
151
114
  () => getInitialActiveAuthMethods(settings)
152
115
  );
153
- const [isTouched, setIsTouched] = useState(false);
154
116
 
155
117
  const handleSuccess = () => {
156
118
  const authMethods = (
@@ -162,7 +124,6 @@ export const AuthSetupDrawer = ({
162
124
  org_unit_id: id,
163
125
  org_unit_name: name,
164
126
  auth_methods: authMethods,
165
- identifiers: selectedIdentifiers,
166
127
  two_factor_enabled: false,
167
128
  });
168
129
 
@@ -198,16 +159,6 @@ export const AuthSetupDrawer = ({
198
159
  },
199
160
  });
200
161
 
201
- const handleIdentifierChange = (identifierId: string) => {
202
- if (identifierId === "userName") return; // Username is always required
203
-
204
- setSelectedIdentifiers((prev) =>
205
- prev.includes(identifierId)
206
- ? prev.filter((id) => id !== identifierId)
207
- : [...prev, identifierId]
208
- );
209
- };
210
-
211
162
  const handleAuthMethodChange = (method: AuthenticationMethod) => {
212
163
  setActiveAuthMethods((prev) => {
213
164
  const next = { ...prev, [method]: !prev[method] };
@@ -229,21 +180,7 @@ export const AuthSetupDrawer = ({
229
180
  };
230
181
 
231
182
  const handleConfirmClick = () => {
232
- setIsTouched(true);
233
-
234
- if (!selectedIdentifiers.includes("userName")) {
235
- return;
236
- }
237
-
238
- const emailAllowed = selectedIdentifiers.includes("email");
239
- const phoneAllowed = selectedIdentifiers.includes("phone");
240
-
241
183
  const data: OrgUnitSettings = {
242
- userIdentification: {
243
- userName: true,
244
- email: emailAllowed,
245
- phone: phoneAllowed,
246
- },
247
184
  authenticationMethods: {
248
185
  method: (
249
186
  Object.values(AuthenticationMethod) as AuthenticationMethod[]
@@ -261,49 +198,12 @@ export const AuthSetupDrawer = ({
261
198
  });
262
199
  };
263
200
 
264
- const isConfirmButtonEnabled =
265
- !isUpdateOrgUnitSettingsLoading && selectedIdentifiers.includes("userName");
201
+ const isConfirmButtonEnabled = !isUpdateOrgUnitSettingsLoading;
266
202
 
267
203
  return (
268
204
  <BasicDrawer data-fs-bp-auth-setup-drawer close={close} {...props}>
269
205
  <BasicDrawer.Heading title="Authentication" onClose={close} />
270
206
  <BasicDrawer.Body>
271
- <section data-fs-bp-auth-setup-section>
272
- <div data-fs-bp-auth-setup-section-header>
273
- <h2 data-fs-bp-auth-setup-section-title>User identification</h2>
274
- </div>
275
- <p data-fs-bp-auth-setup-section-description>
276
- Select the identifiers allowed for sign-in
277
- </p>
278
-
279
- <div data-fs-bp-auth-setup-identifiers>
280
- {IDENTIFIERS.map((identifier) => (
281
- <div data-fs-bp-auth-setup-identifier-group-item>
282
- <label
283
- key={identifier.id}
284
- data-fs-bp-auth-setup-identifier
285
- data-disabled={identifier.disabled}
286
- >
287
- <Checkbox
288
- id={identifier.id}
289
- checked={selectedIdentifiers.includes(identifier.id)}
290
- disabled={identifier.disabled}
291
- onChange={() => handleIdentifierChange(identifier.id)}
292
- />
293
- <span data-fs-bp-auth-setup-identifier-label>
294
- {identifier.name}
295
- </span>
296
- </label>
297
- </div>
298
- ))}
299
- </div>
300
-
301
- <ErrorMessage
302
- show={isTouched && !selectedIdentifiers.includes("userName")}
303
- message="Username is required"
304
- />
305
- </section>
306
-
307
207
  <section data-fs-bp-auth-setup-section>
308
208
  <div data-fs-bp-auth-setup-section-header>
309
209
  <h2 data-fs-bp-auth-setup-section-title>Authentication methods</h2>
@@ -6,11 +6,6 @@ export enum AuthenticationMethod {
6
6
  }
7
7
 
8
8
  export type OrgUnitSettings = {
9
- userIdentification: {
10
- userName: boolean;
11
- email: boolean;
12
- phone: boolean;
13
- };
14
9
  authenticationMethods: {
15
10
  method: Array<{
16
11
  method: AuthenticationMethod;
@@ -21,10 +16,3 @@ export type OrgUnitSettings = {
21
16
  verificationCode: boolean;
22
17
  };
23
18
  };
24
-
25
- export type Identifier = {
26
- id: string;
27
- name: string;
28
- disabled: boolean;
29
- defaultChecked?: boolean;
30
- };
@@ -10,4 +10,4 @@ export type {
10
10
  OrgUnitSearchResponse,
11
11
  } from "./OrgUnitSearch";
12
12
  export { AuthenticationMethod } from "./OrgUnitSettings";
13
- export type { OrgUnitSettings, Identifier } from "./OrgUnitSettings";
13
+ export type { OrgUnitSettings } from "./OrgUnitSettings";
@@ -111,14 +111,14 @@ export const LoadingTabsLayout = ({ children }: LoadingTabsLayoutProps) => {
111
111
  },
112
112
  },
113
113
  settings: {
114
- userIdentification: { userName: true, email: true, phone: true },
115
114
  authenticationMethods: { method: [] },
116
115
  "2FA": { verificationCode: false },
117
116
  },
118
117
  user: {
118
+ id: "",
119
+ login: "",
119
120
  name: "",
120
121
  roles: [],
121
- id: "",
122
122
  orgUnit: {
123
123
  id: routeParams.orgUnitId,
124
124
  name: "",